using Adw; using Gtk; using Gee; public class TranscriptView : Adw.Bin { public MessageListModel? model { get { return _model; } set { if (_model != null) { _model.disconnect(messages_changed_handler_id); _model.unwatch_updates(); } _model = value; if (value != null) { reset_for_conversation_change(); weak TranscriptView self = this; messages_changed_handler_id = value.messages_changed.connect(() => { self.reload_messages(); }); value.load_messages(); value.watch_updates(); } else { transcript_drawing_area.set_messages(new ArrayList()); } } } public string title { get { return title_label.label; } set { title_label.label = value; } } private MessageListModel? _model = null; private Adw.ToolbarView container; private Label title_label = new Label("Messages"); private Overlay overlay = new Overlay(); private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea(); private ScrolledWindow scrolled_window = new ScrolledWindow(); private ulong messages_changed_handler_id = 0; private bool needs_reload = false; private Graphene.Point _hovering_text_view_origin = Graphene.Point() { x = 0, y = 0 }; private TextView _hovering_text_view = new TextView(); private bool _queued_url_open = false; private VisibleLayout? hovered_text_bubble = null; private VisibleLayout? locked_text_bubble = null; public TranscriptView() { container = new Adw.ToolbarView(); set_child(container); // Set minimum width for the transcript view set_size_request(330, -1); overlay.set_child(transcript_drawing_area); weak TranscriptView self = this; overlay.get_child_position.connect((child, out allocation) => { allocation = Gtk.Allocation() { x = 0, y = 0, width = 0, height = 0 }; if (self.hovered_text_bubble != null) { var rect = self.hovered_text_bubble.rect; allocation.x = (int)(rect.origin.x + self._hovering_text_view_origin.x); allocation.y = (int)(rect.origin.y - self._hovering_text_view_origin.y); allocation.width = (int)rect.size.width; allocation.height = (int)rect.size.height; return true; } return true; }); scrolled_window.set_child(overlay); scrolled_window.add_css_class("flipped-y-axis"); transcript_drawing_area.viewport = scrolled_window.vadjustment; container.set_content(scrolled_window); // Connect to the adjustment's value_changed signal scrolled_window.vadjustment.value_changed.connect(() => { transcript_drawing_area.viewport = scrolled_window.vadjustment; }); var header_bar = new Adw.HeaderBar(); title_label.single_line_mode = true; title_label.ellipsize = Pango.EllipsizeMode.END; header_bar.set_title_widget(title_label); container.add_top_bar(header_bar); // This is an invisible text view that's used to handle selection. _hovering_text_view.add_css_class("hovering-text-view"); _hovering_text_view.add_css_class("flipped-y-axis"); _hovering_text_view.set_editable(false); _hovering_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR); overlay.add_overlay(_hovering_text_view); // When the selection changes, lock the text bubble so that if the cursor moves to another bubble we don't clear it. _hovering_text_view.buffer.mark_set.connect((location, end) => { if (location.get_offset() == 0) { return; } self.on_hovering_text_view_clicked(location, end); }); // When the mouse hovers over a text bubble, configure the hovering text view to show the text of the bubble. transcript_drawing_area.on_text_bubble_hover.connect((visible_text_layout) => { if (visible_text_layout != null) { configure_hovering_text_view(visible_text_layout); } }); // This is triggered when another bubble is currently locked, and the user clicks on a new bubble. transcript_drawing_area.on_text_bubble_click.connect((visible_text_layout) => { if (visible_text_layout != null) { locked_text_bubble = null; configure_hovering_text_view(visible_text_layout); } }); transcript_drawing_area.on_image_bubble_activate.connect((attachment_guid) => { self.open_attachment(attachment_guid); }); Repository.get_instance().attachment_downloaded.connect((attachment_guid) => { debug("Attachment downloaded: %s", attachment_guid); // See if this attachment is part of this transcript. bool contains_attachment = false; foreach (var message in _model.messages) { foreach (var attachment in message.attachments) { if (attachment.guid == attachment_guid) { contains_attachment = true; break; } } } if (contains_attachment && !needs_reload) { debug("Queueing reload of messages for attachment download"); needs_reload = true; GLib.Idle.add(() => { if (needs_reload) { debug("Reloading messages for attachment download"); model.load_messages(); needs_reload = false; } return false; }, GLib.Priority.HIGH); } }); } delegate void OpenPath(string path); private ulong attachment_downloaded_handler_id = 0; private void open_attachment(string attachment_guid) { OpenPath open_path = (path) => { try { GLib.AppInfo.launch_default_for_uri("file://" + path, null); } catch (GLib.Error e) { warning("Failed to open image %s: %s", path, e.message); } }; try { var attachment_info = Repository.get_instance().get_attachment_info(attachment_guid); if (attachment_info.downloaded == true) { // We already have it, so open it. open_path(attachment_info.path); } else { // We need to download this, then open it once the downloaded signal is emitted. Repository.get_instance().download_attachment(attachment_guid, false); // TODO: Should probably indicate progress here. attachment_downloaded_handler_id = Repository.get_instance().attachment_downloaded.connect((guid) => { if (guid == attachment_guid) { open_path(attachment_info.path); Repository.get_instance().disconnect(attachment_downloaded_handler_id); } }); } } catch (GLib.Error e) { warning("Failed to get attachment info: %s", e.message); } } private void on_hovering_text_view_clicked(TextIter location, TextMark end) { lock_text_bubble(hovered_text_bubble); if (!_queued_url_open) { _queued_url_open = true; // 100ms timeout to let the selection state settle. (this is a workaround) GLib.Timeout.add(100, () => { open_url_at_location(location); _queued_url_open = false; return false; }, GLib.Priority.HIGH); } } private void open_url_at_location(TextIter location) { Gtk.TextTag? underline_tag = null; foreach (unowned Gtk.TextTag tag in location.get_tags()) { if (tag.underline != Pango.Underline.NONE) { underline_tag = tag; break; } } if (underline_tag == null) { return; // Click wasn't on an underlined (i.e. link) region } // Determine the full extent (start/end iters) of this underlined region Gtk.TextIter start_iter = location; Gtk.TextIter end_iter = location; start_iter.backward_to_tag_toggle(underline_tag); end_iter.forward_to_tag_toggle(underline_tag); string url = _hovering_text_view.buffer.get_text(start_iter, end_iter, false); // Try to open the URL – guard against malformed data if (url != null && url.strip().length > 0) { try { GLib.AppInfo.launch_default_for_uri(url.strip(), null); } catch (GLib.Error e) { warning("Failed to open URL %s: %s", url, e.message); } } } private void reset_for_conversation_change() { locked_text_bubble = null; hovered_text_bubble = null; _hovering_text_view.buffer.text = ""; overlay.queue_allocate(); // Reset scroll position by updating the existing adjustment scrolled_window.vadjustment.value = 0; scrolled_window.vadjustment.upper = 0; scrolled_window.vadjustment.page_size = 0; } private void lock_text_bubble(VisibleLayout? visible_text_layout) { if (visible_text_layout != null) { locked_text_bubble = visible_text_layout; configure_hovering_text_view(visible_text_layout); } } private void configure_hovering_text_view(VisibleLayout? visible_text_layout) { hovered_text_bubble = visible_text_layout; if (locked_text_bubble == null) { TextBubbleLayout text_bubble = visible_text_layout.bubble as TextBubbleLayout; _hovering_text_view_origin = text_bubble.get_text_origin(); _hovering_text_view.buffer.text = ""; Gtk.TextIter start_iter; _hovering_text_view.buffer.get_start_iter(out start_iter); _hovering_text_view.buffer.insert_markup(ref start_iter, text_bubble.message.markup, -1); overlay.queue_allocate(); } } private void reload_messages() { transcript_drawing_area.show_sender = _model.is_group_chat; transcript_drawing_area.set_messages(_model.messages); } }