diff --git a/src/models/message.vala b/src/models/message.vala index e3cf1d6..ce315d8 100644 --- a/src/models/message.vala +++ b/src/models/message.vala @@ -30,6 +30,22 @@ public class Message : Object, Comparable, Hashable } } + public string markup { + owned get { + const string link_regex_pattern = "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)"; + + try { + var regex = new GLib.Regex(link_regex_pattern); + + var escaped_text = GLib.Markup.escape_text(this.text); + return regex.replace(escaped_text, escaped_text.length, 0, "\\0"); + } catch (GLib.RegexError e) { + GLib.warning("Error linking text: %s", e.message); + return GLib.Markup.escape_text(this.text); + } + } + } + public Message(string text, DateTime date, string? sender) { this.text = text; this.date = date; diff --git a/src/transcript/layouts/text-bubble-layout.vala b/src/transcript/layouts/text-bubble-layout.vala index 103b920..0ab77e5 100644 --- a/src/transcript/layouts/text-bubble-layout.vala +++ b/src/transcript/layouts/text-bubble-layout.vala @@ -24,7 +24,8 @@ public class TextBubbleLayout : BubbleLayout this.from_me = message.from_me; this.message = message; - layout = parent.create_pango_layout(message.text); + layout = parent.create_pango_layout(null); + layout.set_markup(message.markup, -1); var font_desc = TextBubbleLayout.body_font; layout.set_font_description(font_desc); diff --git a/src/transcript/transcript-view.vala b/src/transcript/transcript-view.vala index 6da5ffc..3c7336c 100644 --- a/src/transcript/transcript-view.vala +++ b/src/transcript/transcript-view.vala @@ -50,6 +50,7 @@ public class TranscriptView : Adw.Bin 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 VisibleTextLayout? hovered_text_bubble = null; private VisibleTextLayout? locked_text_bubble = null; @@ -103,7 +104,8 @@ public class TranscriptView : Adw.Bin // 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) => { - self.lock_text_bubble(self.hovered_text_bubble); + 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. @@ -152,6 +154,53 @@ public class TranscriptView : Adw.Bin }); } + 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; @@ -176,7 +225,11 @@ public class TranscriptView : Adw.Bin if (locked_text_bubble == null) { _hovering_text_view_origin = visible_text_layout.text_bubble.get_text_origin(); - _hovering_text_view.buffer.text = visible_text_layout.text_bubble.message.text; + + _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, visible_text_layout.text_bubble.message.markup, -1); overlay.queue_allocate(); } }