2025-04-30 15:58:47 -07:00
|
|
|
|
using Adw;
|
|
|
|
|
|
using Gtk;
|
2025-04-30 19:12:00 -07:00
|
|
|
|
using Gee;
|
2025-04-30 15:58:47 -07:00
|
|
|
|
|
2025-05-03 22:47:56 -07:00
|
|
|
|
public class TranscriptView : Adw.Bin
|
2025-04-30 15:58:47 -07:00
|
|
|
|
{
|
2025-04-30 19:50:36 -07:00
|
|
|
|
public MessageListModel? model {
|
|
|
|
|
|
get {
|
|
|
|
|
|
return _model;
|
|
|
|
|
|
}
|
|
|
|
|
|
set {
|
2025-05-14 17:37:23 -07:00
|
|
|
|
if (_model != null) {
|
|
|
|
|
|
_model.disconnect(messages_changed_handler_id);
|
|
|
|
|
|
_model.unwatch_updates();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-30 19:50:36 -07:00
|
|
|
|
_model = value;
|
|
|
|
|
|
|
2025-05-14 17:37:23 -07:00
|
|
|
|
if (value != null) {
|
2025-06-18 16:50:14 -07:00
|
|
|
|
reset_for_conversation_change();
|
2025-05-03 23:39:21 -07:00
|
|
|
|
|
2025-05-14 17:37:23 -07:00
|
|
|
|
weak TranscriptView self = this;
|
|
|
|
|
|
messages_changed_handler_id = value.messages_changed.connect(() => {
|
|
|
|
|
|
self.reload_messages();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
value.load_messages();
|
2025-06-12 17:54:09 -07:00
|
|
|
|
value.watch_updates();
|
2025-04-30 19:50:36 -07:00
|
|
|
|
} else {
|
2025-05-14 17:37:23 -07:00
|
|
|
|
transcript_drawing_area.set_messages(new ArrayList<Message>());
|
2025-04-30 19:50:36 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-03 23:26:53 -07:00
|
|
|
|
public string title {
|
|
|
|
|
|
get { return title_label.label; }
|
|
|
|
|
|
set { title_label.label = value; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-30 19:50:36 -07:00
|
|
|
|
private MessageListModel? _model = null;
|
2025-04-30 15:58:47 -07:00
|
|
|
|
private Adw.ToolbarView container;
|
2025-05-03 23:26:53 -07:00
|
|
|
|
private Label title_label = new Label("Messages");
|
2025-04-30 15:58:47 -07:00
|
|
|
|
|
2025-06-18 16:50:14 -07:00
|
|
|
|
private Overlay overlay = new Overlay();
|
2025-05-03 22:47:56 -07:00
|
|
|
|
private TranscriptDrawingArea transcript_drawing_area = new TranscriptDrawingArea();
|
2025-04-30 15:58:47 -07:00
|
|
|
|
private ScrolledWindow scrolled_window = new ScrolledWindow();
|
2025-05-14 17:37:23 -07:00
|
|
|
|
private ulong messages_changed_handler_id = 0;
|
2025-06-06 20:03:02 -07:00
|
|
|
|
private bool needs_reload = false;
|
2025-04-30 15:58:47 -07:00
|
|
|
|
|
2025-06-18 16:50:14 -07:00
|
|
|
|
private Graphene.Point _hovering_text_view_origin = Graphene.Point() { x = 0, y = 0 };
|
|
|
|
|
|
private TextView _hovering_text_view = new TextView();
|
|
|
|
|
|
|
2025-06-18 17:36:32 -07:00
|
|
|
|
private bool _queued_url_open = false;
|
2025-06-18 18:07:59 -07:00
|
|
|
|
private VisibleLayout? hovered_text_bubble = null;
|
|
|
|
|
|
private VisibleLayout? locked_text_bubble = null;
|
2025-06-18 16:50:14 -07:00
|
|
|
|
|
2025-05-14 17:37:23 -07:00
|
|
|
|
public TranscriptView() {
|
2025-04-30 15:58:47 -07:00
|
|
|
|
container = new Adw.ToolbarView();
|
|
|
|
|
|
set_child(container);
|
2025-06-12 20:35:56 -07:00
|
|
|
|
|
|
|
|
|
|
// Set minimum width for the transcript view
|
|
|
|
|
|
set_size_request(330, -1);
|
2025-04-30 15:58:47 -07:00
|
|
|
|
|
2025-06-18 16:50:14 -07:00
|
|
|
|
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");
|
2025-06-16 20:09:56 -07:00
|
|
|
|
transcript_drawing_area.viewport = scrolled_window.vadjustment;
|
2025-04-30 15:58:47 -07:00
|
|
|
|
container.set_content(scrolled_window);
|
|
|
|
|
|
|
2025-06-16 20:09:56 -07:00
|
|
|
|
// Connect to the adjustment's value_changed signal
|
|
|
|
|
|
scrolled_window.vadjustment.value_changed.connect(() => {
|
|
|
|
|
|
transcript_drawing_area.viewport = scrolled_window.vadjustment;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-04-30 15:58:47 -07:00
|
|
|
|
var header_bar = new Adw.HeaderBar();
|
2025-05-12 20:46:12 -07:00
|
|
|
|
title_label.single_line_mode = true;
|
|
|
|
|
|
title_label.ellipsize = Pango.EllipsizeMode.END;
|
2025-05-03 23:26:53 -07:00
|
|
|
|
header_bar.set_title_widget(title_label);
|
2025-04-30 15:58:47 -07:00
|
|
|
|
container.add_top_bar(header_bar);
|
2025-06-06 20:03:02 -07:00
|
|
|
|
|
2025-06-18 16:50:14 -07:00
|
|
|
|
// 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) => {
|
2025-06-18 17:36:32 -07:00
|
|
|
|
if (location.get_offset() == 0) { return; }
|
|
|
|
|
|
self.on_hovering_text_view_clicked(location, end);
|
2025-06-18 16:50:14 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-18 18:07:59 -07:00
|
|
|
|
transcript_drawing_area.on_image_bubble_activate.connect((attachment_guid) => {
|
|
|
|
|
|
self.open_attachment(attachment_guid);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-06 20:03:02 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-04-30 19:50:36 -07:00
|
|
|
|
}
|
2025-04-30 15:58:47 -07:00
|
|
|
|
|
2025-06-18 18:07:59 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-18 17:36:32 -07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-06-18 16:50:14 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-18 18:07:59 -07:00
|
|
|
|
private void lock_text_bubble(VisibleLayout? visible_text_layout) {
|
2025-06-18 16:50:14 -07:00
|
|
|
|
if (visible_text_layout != null) {
|
|
|
|
|
|
locked_text_bubble = visible_text_layout;
|
|
|
|
|
|
configure_hovering_text_view(visible_text_layout);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-18 18:07:59 -07:00
|
|
|
|
private void configure_hovering_text_view(VisibleLayout? visible_text_layout) {
|
2025-06-18 16:50:14 -07:00
|
|
|
|
hovered_text_bubble = visible_text_layout;
|
|
|
|
|
|
|
|
|
|
|
|
if (locked_text_bubble == null) {
|
2025-06-18 18:07:59 -07:00
|
|
|
|
TextBubbleLayout text_bubble = visible_text_layout.bubble as TextBubbleLayout;
|
|
|
|
|
|
_hovering_text_view_origin = text_bubble.get_text_origin();
|
2025-06-18 17:36:32 -07:00
|
|
|
|
|
|
|
|
|
|
_hovering_text_view.buffer.text = "";
|
|
|
|
|
|
Gtk.TextIter start_iter;
|
|
|
|
|
|
_hovering_text_view.buffer.get_start_iter(out start_iter);
|
2025-06-18 18:07:59 -07:00
|
|
|
|
_hovering_text_view.buffer.insert_markup(ref start_iter, text_bubble.message.markup, -1);
|
2025-06-18 16:50:14 -07:00
|
|
|
|
overlay.queue_allocate();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-04-30 19:50:36 -07:00
|
|
|
|
private void reload_messages() {
|
2025-05-03 23:19:15 -07:00
|
|
|
|
transcript_drawing_area.show_sender = _model.is_group_chat;
|
2025-05-03 22:47:56 -07:00
|
|
|
|
transcript_drawing_area.set_messages(_model.messages);
|
2025-04-30 15:58:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|