diff --git a/gtk/.gitignore b/gtk/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/gtk/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/gtk/CLAUDE.md b/gtk/CLAUDE.md new file mode 100644 index 0000000..99ff308 --- /dev/null +++ b/gtk/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Setup build directory (first time) +meson setup builddir + +# Build project +meson compile -C builddir + +# Clean rebuild +rm -rf builddir && meson setup builddir && meson compile -C builddir + +# Run the application +./builddir/src/kordophone +``` + +## Architecture Overview + +Kordophone is a GTK4/Libadwaita messaging client written in Vala that acts as a frontend to a separate DBus daemon (`kordophonecd`). The application follows a clean separation of concerns: + +### Core Architecture +- **DBus Client**: This GTK app connects to `net.buzzert.kordophonecd` daemon over DBus +- **Split UI**: Main window has conversation list sidebar + transcript view +- **Custom Rendering**: Transcript uses Gtk.DrawingArea with snapshot API for performance +- **Repository Pattern**: All data operations go through `Repository` singleton which proxies to DBus service + +### Key Components +- **Application** (`src/application/`): App lifecycle, main window, preferences +- **Service** (`src/service/`): DBus integration, data repository, settings management +- **Conversation List** (`src/conversation-list/`): Sidebar with conversation list and search +- **Transcript** (`src/transcript/`): Message display with pluggable bubble layouts +- **Models** (`src/models/`): Data structures (Conversation, Message, Attachment) + +### DBus Interface +The app depends on a running `kordophonecd` daemon that provides: +- **Repository interface**: Conversation/message CRUD, real-time sync via signals +- **Settings interface**: Server configuration, user credentials + +### Development Notes +- Vala compiles to C, build artifacts in `builddir/` +- DBus interfaces auto-generated from XML: run `src/service/interface/generate.sh` +- Resources bundled via GResource (`src/resources/kordophone.gresource.xml`) +- Custom CSS styling in `src/resources/style.css` +- Application ID: `net.buzzert.kordophone2` + +### Message Layout System +Transcript view uses a pluggable layout system in `src/transcript/layouts/`: +- `BubbleLayout`: Base class for message bubbles +- `TextBubbleLayout`: Text messages +- `ImageBubbleLayout`: Image attachments +- `DateItemLayout`: Date separators +- `SenderAnnotationLayout`: Sender labels + +All layouts render to Cairo context via Gtk snapshot API for efficient scrolling performance. \ No newline at end of file diff --git a/gtk/Dockerfile b/gtk/Dockerfile new file mode 100644 index 0000000..873c2ba --- /dev/null +++ b/gtk/Dockerfile @@ -0,0 +1,30 @@ +FROM fedora:40 + +# Install RPM build tools and dependencies +RUN dnf update -y && dnf install -y \ + rpm-build \ + rpmdevtools \ + meson \ + vala \ + gcc \ + pkgconfig \ + gtk4-devel \ + libadwaita-devel \ + glib2-devel \ + libgee-devel \ + libsecret-devel \ + ImageMagick \ + git \ + && dnf clean all + +# Create RPM build environment +RUN rpmdev-setuptree + +# Set working directory +WORKDIR /root/rpmbuild + +# Copy spec file +COPY dist/rpm/kordophone.spec SPECS/ + +# Build command +CMD ["rpmbuild", "-ba", "SPECS/kordophone.spec"] diff --git a/gtk/README.md b/gtk/README.md new file mode 100644 index 0000000..262c0ff --- /dev/null +++ b/gtk/README.md @@ -0,0 +1,7 @@ +# kordophone-2-gtk + +Libadwaita/GTK4 client for the Kordophone client daemon. + +# Building + +Build an RPM using `rpmbuild -ba dist/rpm/kordophone.spec` diff --git a/gtk/build-aux/post-install.sh b/gtk/build-aux/post-install.sh new file mode 100644 index 0000000..9713074 --- /dev/null +++ b/gtk/build-aux/post-install.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +datadir=$1 + +# Package managers set this so we don't need to run +if [ -z "$DESTDIR" ]; then + echo Compiling GSettings schemas... + glib-compile-schemas ${datadir}/glib-2.0/schemas +fi + diff --git a/gtk/build-aux/resize.py b/gtk/build-aux/resize.py new file mode 100755 index 0000000..d56d78f --- /dev/null +++ b/gtk/build-aux/resize.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +import os, sys + +icon_class = [ + '16x16', + '24x24', + '32x32', + '48x48', + '256x256', + '512x512', +] + +magick = sys.argv[1] +input_file = sys.argv[2] +output = sys.argv[3] + +for iclass in icon_class: + outdir = os.path.join(output, iclass, 'apps') + outfile = os.path.join(outdir, 'net.buzzert.kordophone.png') + + os.makedirs(outdir, exist_ok=True) + os.system('{magick} {input} -resize {klass} {outfile}' + .format(magick=magick, input=input_file, klass=iclass, outfile=outfile)) + + diff --git a/gtk/dist/rpm/kordophone.spec b/gtk/dist/rpm/kordophone.spec new file mode 100644 index 0000000..89cd731 --- /dev/null +++ b/gtk/dist/rpm/kordophone.spec @@ -0,0 +1,51 @@ +Name: kordophone +Version: 1.0.1 +Release: 1%{?dist} +Summary: GTK4/Libadwaita client for Kordophone + +License: GPL +URL: https://git.sr.ht/~buzzert/kordophone-2-gtk + +BuildRequires: meson >= 0.56.0 +BuildRequires: vala +BuildRequires: gcc +BuildRequires: pkgconfig(gtk4) +BuildRequires: pkgconfig(libadwaita-1) +BuildRequires: pkgconfig(gio-2.0) +BuildRequires: pkgconfig(gee-0.8) +BuildRequires: pkgconfig(gio-unix-2.0) +BuildRequires: pkgconfig(libsecret-1) + +Requires: gtk4 +Requires: libadwaita +Requires: glib2 +Requires: libgee +Requires: libsecret +Requires: kordophoned >= 1.0.0 + +%description +A GTK4/Libadwaita Linux Client for the Kordophone client daemon. + +%prep +if [ ! -d .git ]; then + git clone --bare https://git.sr.ht/~buzzert/kordophone-2-gtk .git + git config --local --bool core.bare false + git reset --hard +fi + +%build +%meson +%meson_build + +%install +%meson_install + +%files +%{_bindir}/kordophone +%{_datadir}/applications/net.buzzert.kordophone.desktop +%{_datadir}/icons/ + +%changelog +* Fri Aug 8 2025 James Magahern +- Updated rpmspec + diff --git a/gtk/meson.build b/gtk/meson.build new file mode 100644 index 0000000..317872f --- /dev/null +++ b/gtk/meson.build @@ -0,0 +1,7 @@ +project('kordophone', 'vala', + version : '1.0.1', + meson_version : '>=0.56.0', + default_options : ['warning_level=2'] +) + +subdir('src') diff --git a/gtk/src/application/kordophone-application.vala b/gtk/src/application/kordophone-application.vala new file mode 100644 index 0000000..986b003 --- /dev/null +++ b/gtk/src/application/kordophone-application.vala @@ -0,0 +1,50 @@ +using Adw; +using Gtk; + +public class KordophoneApp : Adw.Application +{ + private MainWindow window; + + public KordophoneApp () { + Object (application_id: "net.buzzert.kordophone2", flags: ApplicationFlags.FLAGS_NONE); + } + + protected override void startup () { + base.startup (); + + // Set default icon theme + Gtk.Settings.get_default().set_property("gtk-icon-theme-name", "Adwaita"); + + // Load CSS from resources + var provider = new Gtk.CssProvider (); + provider.load_from_resource ("/net/buzzert/kordophone2/style.css"); + Gtk.StyleContext.add_provider_for_display ( + Gdk.Display.get_default (), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ); + + // Setup application actions + var quit_action = new SimpleAction("quit", null); + quit_action.activate.connect(() => { + this.quit(); + }); + add_action(quit_action); + + // Warm up dbus connections + Repository.get_instance(); + } + + protected override void activate () { + window = new MainWindow (); + window.set_default_size (1200, 1000); + window.application = this; + + window.present (); + } + + public static int main (string[] args) { + var app = new KordophoneApp (); + return app.run (args); + } +} \ No newline at end of file diff --git a/gtk/src/application/main-window.vala b/gtk/src/application/main-window.vala new file mode 100644 index 0000000..f649ad1 --- /dev/null +++ b/gtk/src/application/main-window.vala @@ -0,0 +1,116 @@ +using Adw; +using Gtk; + +public class MainWindow : Adw.ApplicationWindow +{ + private ConversationListView conversation_list_view; + private TranscriptContainerView transcript_container_view; + + private EventControllerMotion _motion_controller = new EventControllerMotion(); + private bool _motion_queued = false; + + public MainWindow () { + Object (title: "Kordophone"); + + var split_view = new NavigationSplitView (); + split_view.set_min_sidebar_width (400); + set_content (split_view); + + conversation_list_view = new ConversationListView (); + conversation_list_view.conversation_selected.connect (conversation_selected); + conversation_list_view.conversation_activated.connect (open_conversation_in_new_window); + + var conversation_list_page = new NavigationPage (conversation_list_view, "Conversations"); + split_view.sidebar = conversation_list_page; + + transcript_container_view = new TranscriptContainerView (); + var transcript_page = new NavigationPage (transcript_container_view, "Transcript"); + split_view.content = transcript_page; + + var show_settings_action = new SimpleAction ("settings", null); + show_settings_action.activate.connect(show_settings); + add_action(show_settings_action); + + _motion_controller.motion.connect((x, y) => { + queue_motion(); + }); + _motion_controller.set_propagation_phase(PropagationPhase.CAPTURE); + split_view.add_controller(_motion_controller); + } + + private void queue_motion() { + if (_motion_queued) { + return; + } + + if (!is_active) { + return; + } + + _motion_queued = true; + GLib.Timeout.add(500, () => { + _motion_queued = false; + on_motion(); + return false; + }); + } + + private void on_motion() { + var selected_conversation = transcript_container_view.transcript_view.model?.conversation; + if (selected_conversation == null) { + return; + } + + var list_model = conversation_list_view.conversation_model; + var conversation_in_list = list_model.get_conversation(selected_conversation.guid); + if (conversation_in_list == null) { + return; + } + + if (conversation_in_list.unread_count == 0) { + return; + } + + try { + GLib.message("Marking conversation as read on motion: %s", selected_conversation.guid); + Repository.get_instance().mark_conversation_as_read(selected_conversation.guid); + } catch (Error e) { + GLib.warning("Failed to mark conversation as read: %s", e.message); + } + } + + private void show_settings () { + var dialog = new PreferencesWindow (this); + dialog.present (this); + } + + private void conversation_selected(Conversation conversation) { + TranscriptView transcript_view = transcript_container_view.transcript_view; + if (conversation == null) { + transcript_view.model = null; + } else { + if (transcript_view.model == null || transcript_view.model.conversation.guid != conversation.guid) { + transcript_view.model = new MessageListModel (conversation); + transcript_view.title = conversation.display_name; + + try { + Repository.get_instance().mark_conversation_as_read(conversation.guid); + Repository.get_instance().sync_conversation(conversation.guid); + } catch (Error e) { + GLib.warning("Failed to sync conversation: %s", e.message); + } + } + } + } + + private void open_conversation_in_new_window(Conversation conversation) { + var view = new TranscriptContainerView(); + view.transcript_view.model = new MessageListModel (conversation); + view.transcript_view.title = conversation.display_name; + + var window = new Adw.Window(); + window.set_default_size(750, 990); + window.set_content(view); + window.present(); + } +} diff --git a/gtk/src/application/preferences-window.vala b/gtk/src/application/preferences-window.vala new file mode 100644 index 0000000..4f11f82 --- /dev/null +++ b/gtk/src/application/preferences-window.vala @@ -0,0 +1,69 @@ +using Adw; +using Gtk; + +public class PreferencesWindow : Adw.PreferencesDialog { + private Adw.EntryRow server_url_row; + private Adw.EntryRow username_row; + private Adw.PasswordEntryRow password_row; + private Settings settings; + + public PreferencesWindow (Gtk.Window parent) { + Object ( + title: "Settings" + ); + + add_css_class ("settings-dialog"); + + var page = new PreferencesPage (); + page.margin_top = 14; + page.margin_bottom = 14; + page.margin_start = 50; + page.margin_end = 50; + add (page); + + var connection_group = new PreferencesGroup (); + connection_group.title = "Connection Settings"; + page.add (connection_group); + + server_url_row = new Adw.EntryRow (); + server_url_row.title = "Server URL"; + connection_group.add (server_url_row); + + username_row = new Adw.EntryRow (); + username_row.title = "Username"; + connection_group.add (username_row); + + password_row = new Adw.PasswordEntryRow (); + password_row.title = "Password"; + connection_group.add (password_row); + + settings = new Settings(); + settings.settings_ready.connect(load_settings); + load_settings(); + + unowned var self = this; + closed.connect(() => { + self.save_settings(); + }); + } + + private void load_settings() { + try { + username_row.text = settings.get_username(); + server_url_row.text = settings.get_server_url(); + password_row.text = settings.get_password(); + } catch (Error e) { + warning("Failed to load settings: %s", e.message); + } + } + + private void save_settings() { + try { + settings.set_server_url(server_url_row.text); + settings.set_username(username_row.text); + settings.set_password(password_row.text); + } catch (Error e) { + warning("Failed to save settings: %s", e.message); + } + } +} \ No newline at end of file diff --git a/gtk/src/conversation-list/conversation-list-model.vala b/gtk/src/conversation-list/conversation-list-model.vala new file mode 100644 index 0000000..dc87912 --- /dev/null +++ b/gtk/src/conversation-list/conversation-list-model.vala @@ -0,0 +1,150 @@ +using GLib; +using Gee; + +public class ConversationListModel : Object, ListModel +{ + public SortedSet conversations { + owned get { return _conversations.read_only_view; } + } + + private SortedSet _conversations; + + public ConversationListModel() { + _conversations = new TreeSet((a, b) => { + // Sort by date in descending order (newest first) + return (int)(b.date - a.date); + }); + + weak ConversationListModel self = this; + Repository.get_instance().conversations_updated.connect(() => { + self.load_conversations(); + }); + + Repository.get_instance().messages_updated.connect((conversation_guid) => { + self.load_conversations(); + }); + + Repository.get_instance().reconnected.connect(() => { + // Trigger a sync-list to get the latest conversations + try { + Repository.get_instance().sync_conversation_list(); + } catch (GLib.Error e) { + warning("Failed to sync conversation list: %s", e.message); + } + }); + } + + public Conversation? get_conversation(string guid) { + foreach (var conv in _conversations) { + if (conv.guid == guid) { + return conv; + } + } + + return null; + } + + public void load_conversations() { + try { + Conversation[] new_conversations = Repository.get_instance().get_conversations(); + + // Create a map of old conversations for quick lookup + var old_conversations_map = new HashMap(); + foreach (var conv in _conversations) { + old_conversations_map[conv.guid] = conv; + } + + // Create a map of new conversations for quick lookup + var new_conversations_map = new HashMap(); + foreach (var conv in new_conversations) { + new_conversations_map[conv.guid] = conv; + } + + // Find removed conversations + var removed_positions = new ArrayList(); + var current_position = 0; + foreach (var old_conv in _conversations) { + if (!new_conversations_map.has_key(old_conv.guid)) { + removed_positions.add(current_position); + } + current_position++; + } + + // Remove conversations in reverse order to maintain correct positions + for (int i = removed_positions.size - 1; i >= 0; i--) { + var pos = removed_positions[i]; + _conversations.remove(_conversations.to_array()[pos]); + items_changed(pos, 1, 0); + } + + // Find added conversations and changed conversations + var added_conversations = new ArrayList(); + var changed_conversations = new ArrayList(); + foreach (var new_conv in new_conversations) { + if (!old_conversations_map.has_key(new_conv.guid)) { + added_conversations.add(new_conv); + } else { + var old_conv = old_conversations_map[new_conv.guid]; + if (!old_conv.equals(new_conv)) { + changed_conversations.add(new_conv); + } + } + } + + // Add new conversations + foreach (var conv in added_conversations) { + _conversations.add(conv); + // Find the position by counting how many items are before this one + uint pos = 0; + foreach (var existing_conv in _conversations) { + if (existing_conv.guid == conv.guid) break; + pos++; + } + items_changed(pos, 0, 1); + } + + // Update changed conversations + foreach (var conv in changed_conversations) { + // Find position of old conversation + uint old_pos = 0; + var old_conv = old_conversations_map[conv.guid]; + foreach (var existing_conv in _conversations) { + if (existing_conv.guid == old_conv.guid) break; + old_pos++; + } + + // Remove the old one + _conversations.remove(old_conv); + + // Add the new one + _conversations.add(conv); + + // Find the new (sorted) position + uint new_pos = 0; + foreach (var existing_conv in _conversations) { + if (existing_conv.guid == conv.guid) break; + new_pos++; + } + + // Notify of the change + items_changed(old_pos, 1, 0); + items_changed(new_pos, 0, 1); + } + } catch (Error e) { + warning("Failed to load conversations: %s", e.message); + } + } + + // ListModel implementation + public Type get_item_type() { + return typeof(Conversation); + } + + public uint get_n_items() { + return _conversations.size; + } + + public Object? get_item(uint position) { + return _conversations.to_array()[position]; + } +} \ No newline at end of file diff --git a/gtk/src/conversation-list/conversation-list-view.vala b/gtk/src/conversation-list/conversation-list-view.vala new file mode 100644 index 0000000..ba5e952 --- /dev/null +++ b/gtk/src/conversation-list/conversation-list-view.vala @@ -0,0 +1,126 @@ +using Adw; +using Gtk; + +public class ConversationListView : Adw.Bin +{ + public ConversationListModel conversation_model { get; private set; } + public signal void conversation_selected(Conversation conversation); + public signal void conversation_activated(Conversation conversation); + + private Adw.ToolbarView container; + private ListBox list_box; + private ScrolledWindow scrolled_window; + private Adw.HeaderBar header_bar; + + private string? selected_conversation_guid = null; + private bool selection_update_queued = false; + + public ConversationListView () { + container = new Adw.ToolbarView (); + set_child (container); + + scrolled_window = new ScrolledWindow (); + container.set_content (scrolled_window); + + list_box = new ListBox (); + list_box.add_css_class ("boxed-list"); + list_box.set_selection_mode (SelectionMode.SINGLE); + list_box.activate_on_single_click = false; + scrolled_window.set_child (list_box); + + list_box.row_selected.connect ((row) => { + var conversation_row = (ConversationRow?) row; + if (conversation_row != null) { + selected_conversation_guid = conversation_row.conversation.guid; + + Conversation conversation = conversation_model.get_conversation(selected_conversation_guid); + conversation_selected(conversation); + } + }); + + list_box.row_activated.connect((row) => { + var conversation_row = (ConversationRow?) row; + if (conversation_row != null) { + Conversation conversation = conversation_row.conversation; + conversation_activated(conversation); + } + }); + + header_bar = new Adw.HeaderBar (); + header_bar.set_title_widget (new Label ("Kordophone")); + container.add_top_bar (header_bar); + + // Setup application menu + var app_menu = new Menu (); + + var section = new Menu (); + section.append ("Manual Sync", "list.refresh"); + section.append ("Settings...", "win.settings"); + app_menu.append_section (null, section); + + section = new Menu (); + section.append ("Quit", "app.quit"); + app_menu.append_section (null, section); + + var refresh_action = new SimpleAction("refresh", null); + refresh_action.activate.connect (() => { + try { + Repository.get_instance().sync_conversation_list(); + } catch (GLib.Error e) { + warning("Failed to sync conversation list: %s", e.message); + } + }); + + var action_group = new SimpleActionGroup (); + action_group.add_action(refresh_action); + insert_action_group ("list", action_group); + + var menu_button = new Gtk.MenuButton (); + menu_button.menu_model = app_menu; + menu_button.primary = true; + menu_button.icon_name = "open-menu-symbolic"; + header_bar.pack_end (menu_button); + + // Set up model and bind to list + conversation_model = new ConversationListModel (); + conversation_model.items_changed.connect (on_items_changed); + list_box.bind_model (conversation_model, create_conversation_row); + } + + private void on_items_changed (uint position, uint removed, uint added) { + enqueue_selection_update(); + } + + private void enqueue_selection_update() { + if (selection_update_queued) { + return; + } + + selection_update_queued = true; + GLib.Idle.add(() => { + update_selection(); + selection_update_queued = false; + return false; + }, GLib.Priority.HIGH); + } + + private void update_selection() { + // Re-select selected_conversation_guid, if it has changed. + if (selected_conversation_guid != null) { + for (uint i = 0; i < conversation_model.get_n_items(); i++) { + var conversation = (Conversation) conversation_model.get_item(i); + if (conversation.guid == selected_conversation_guid) { + var row = list_box.get_row_at_index((int)i); + if (row != null) { + list_box.select_row(row); + } + } + } + } + } + + private Widget create_conversation_row (Object item) { + Conversation conversation = (Conversation) item; + return new ConversationRow (conversation); + } +} \ No newline at end of file diff --git a/gtk/src/conversation-list/conversation-row.vala b/gtk/src/conversation-list/conversation-row.vala new file mode 100644 index 0000000..be13c37 --- /dev/null +++ b/gtk/src/conversation-list/conversation-row.vala @@ -0,0 +1,61 @@ +using Adw; +using Gtk; + +public class ConversationRow : Adw.ActionRow { + public Conversation conversation; + private Image unread_badge; + + public ConversationRow(Conversation conversation) { + this.conversation = conversation; + this.activatable = true; + + title = conversation.display_name.strip(); + title_lines = 1; + + var preview = conversation.last_message_preview + .strip() + .replace("\n", " ") + .replace("<", "\\<") + .replace(">", "\\>") + .replace("&", "&"); + + subtitle = preview.length > 100 ? preview.substring(0, 100) : preview; + subtitle_lines = 1; + + add_css_class("conversation-row"); + + unread_badge = new Image.from_icon_name ("media-record-symbolic"); + unread_badge.add_css_class("badge"); + unread_badge.add_css_class("accent"); + add_prefix(unread_badge); + + if (conversation.is_unread) { + unread_badge.opacity = 1.0; + } else { + unread_badge.opacity = 0.0; + } + + // Add timestamp if available + if (conversation.date > 0) { + var datetime = new DateTime.from_unix_local(conversation.date); + if (datetime != null) { + var now = new DateTime.now_local(); + + string time_str; + if (datetime.get_year() == now.get_year() && + datetime.get_day_of_year() == now.get_day_of_year()) { + // Today - show time + time_str = datetime.format("%H:%M"); + } else { + // Not today - show date + time_str = datetime.format("%b %d"); + } + + var time_label = new Label(time_str); + time_label.add_css_class("dim-label"); + time_label.margin_start = 8; + add_suffix(time_label); + } + } + } +} diff --git a/gtk/src/meson.build b/gtk/src/meson.build new file mode 100644 index 0000000..12ade82 --- /dev/null +++ b/gtk/src/meson.build @@ -0,0 +1,91 @@ +dependencies = [ + dependency('gtk4', required : true), + dependency('libadwaita-1', required : true), + dependency('gio-2.0', required : true), + dependency('gee-0.8', required : true), + dependency('gio-unix-2.0', required : true), + dependency('libsecret-1', required : true), +] + +gnome = import('gnome') +kp_prefix = get_option('prefix') +datadir = join_paths(kp_prefix, get_option('datadir')) +resources = gnome.compile_resources( + 'kordophone-resources', + 'resources/kordophone.gresource.xml', + source_dir: 'resources' +) + +# Icons +app_icon_dirs = [ + '16x16', + '24x24', + '32x32', + '48x48', + '256x256', + '512x512', +] + +build_tools_dir = meson.source_root() / 'build-aux' + +image_magick = find_program('convert', required : true) +resizer = find_program(build_tools_dir / 'resize.py') +icons = custom_target('icons', + output: 'hicolor', + input: 'resources/net.buzzert.kordophone.png', + command: [resizer, image_magick, '@INPUT@', '@OUTPUT@'], + install: true, + install_dir: join_paths(datadir, 'icons'), +) + +# Full res icon for Desktop Entry +install_data('resources/net.buzzert.kordophone.png', + install_dir: join_paths(datadir, 'icons'), +) + +# Desktop +install_data('resources/net.buzzert.kordophone.desktop', + install_dir: join_paths(datadir, 'applications') +) + +sources = [ + 'application/kordophone-application.vala', + 'application/main-window.vala', + 'application/preferences-window.vala', + + 'service/interface/dbusservice.vala', + 'service/dbus-service-base.vala', + 'service/repository.vala', + 'service/settings.vala', + + 'conversation-list/conversation-list-view.vala', + 'conversation-list/conversation-list-model.vala', + 'conversation-list/conversation-row.vala', + + 'transcript/attachment-preview.vala', + 'transcript/message-list-model.vala', + 'transcript/transcript-container-view.vala', + 'transcript/transcript-drawing-area.vala', + 'transcript/transcript-view.vala', + + 'transcript/layouts/bubble-layout.vala', + 'transcript/layouts/chat-item-layout.vala', + 'transcript/layouts/date-item-layout.vala', + 'transcript/layouts/image-bubble-layout.vala', + 'transcript/layouts/sender-annotation-layout.vala', + 'transcript/layouts/text-bubble-layout.vala', + + 'models/attachment.vala', + 'models/conversation.vala', + 'models/message.vala', +] + +executable('kordophone', + sources, + resources, + icons, + dependencies : dependencies, + vala_args: ['--pkg', 'posix'], + link_args: ['-lm'], + install : true +) diff --git a/gtk/src/models/attachment.vala b/gtk/src/models/attachment.vala new file mode 100644 index 0000000..24e9596 --- /dev/null +++ b/gtk/src/models/attachment.vala @@ -0,0 +1,88 @@ +public class AttributionInfo : Object { + // Picture width + public int64 width; + + // Picture height + public int64 height; + + public static AttributionInfo from_variant(Variant variant) { + var attribution_info = new AttributionInfo(); + + VariantDict dict = new VariantDict(variant); + attribution_info.width = dict.lookup_value("width", VariantType.INT32)?.get_int32() ?? 0; + attribution_info.height = dict.lookup_value("height", VariantType.INT32)?.get_int32() ?? 0; + + return attribution_info; + } +} + +public class AttachmentMetadata : Object { + public AttributionInfo? attribution_info = null; + + public static AttachmentMetadata from_variant(Variant variant) { + var metadata = new AttachmentMetadata(); + + VariantDict dict = new VariantDict(variant); + metadata.attribution_info = AttributionInfo.from_variant(dict.lookup_value("attribution_info", VariantType.DICTIONARY)); + + return metadata; + } +} + +public class AttachmentInfo : Object { + public string? path; + public string? preview_path; + public bool? downloaded; + public bool? preview_downloaded; + + public AttachmentInfo(string? path, string? preview_path, bool? downloaded, bool? preview_downloaded) { + this.path = path; + this.preview_path = preview_path; + this.downloaded = downloaded; + this.preview_downloaded = preview_downloaded; + } +} + +public class Attachment : Object { + public string guid; + public string path; + public string preview_path; + public bool downloaded; + public bool preview_downloaded; + public AttachmentMetadata? metadata; + + public Attachment(string guid, AttachmentMetadata? metadata) { + this.guid = guid; + this.metadata = metadata; + } + + public Attachment.from_variant(Variant variant) { + VariantIter iter; + variant.get("a{sv}", out iter); + + string key; + Variant val; + while (iter.next("{sv}", out key, out val)) { + switch (key) { + case "guid": + this.guid = val.get_string(); + break; + case "path": + this.path = val.get_string(); + break; + case "preview_path": + this.preview_path = val.get_string(); + break; + case "downloaded": + this.downloaded = val.get_boolean(); + break; + case "preview_downloaded": + this.preview_downloaded = val.get_boolean(); + break; + case "metadata": + this.metadata = AttachmentMetadata.from_variant(val); + break; + } + } + } +} diff --git a/gtk/src/models/conversation.vala b/gtk/src/models/conversation.vala new file mode 100644 index 0000000..563d84f --- /dev/null +++ b/gtk/src/models/conversation.vala @@ -0,0 +1,72 @@ +using GLib; + +public class Conversation : Object { + public string guid { get; set; default = ""; } + public string last_message_preview { get; set; default = ""; } + public int64 date { get; set; default = 0; } + public string[] participants { get; set; default = new string[0]; } + public int unread_count { get; set; default = 0; } + + public bool is_unread { get { return unread_count > 0; } } + + public string display_name { + owned get { + if (_display_name != null && _display_name.length > 0) { + return _display_name; + } + + if (participants.length == 1) { + return participants[0]; + } + + if (participants.length > 1) { + return string.joinv(", ", participants); + } + + return "Untitled"; + } + } + + private string? _display_name = null; + + public Conversation.from_hash_table(HashTable conversation_data) { + guid = conversation_data["guid"].get_string(); + + if (conversation_data.contains("last_message_preview")) { + last_message_preview = conversation_data["last_message_preview"].get_string(); + } + + if (conversation_data.contains("participants")) { + participants = conversation_data["participants"].dup_strv(); + } + + if (conversation_data.contains("unread_count")) { + unread_count = conversation_data["unread_count"].get_int32(); + } + + if (conversation_data.contains("date")) { + date = conversation_data["date"].get_int64(); + } + + if (conversation_data.contains("display_name")) { + _display_name = conversation_data["display_name"].get_string(); + } + } + + public bool equals(Conversation other) { + if (other == null) return false; + if (guid != other.guid) return false; + if (date != other.date) return false; + if (unread_count != other.unread_count) return false; + if (last_message_preview != other.last_message_preview) return false; + if (_display_name != other._display_name) return false; + + // Compare participants arrays + if (participants.length != other.participants.length) return false; + for (int i = 0; i < participants.length; i++) { + if (participants[i] != other.participants[i]) return false; + } + + return true; + } +} \ No newline at end of file diff --git a/gtk/src/models/message.vala b/gtk/src/models/message.vala new file mode 100644 index 0000000..ce315d8 --- /dev/null +++ b/gtk/src/models/message.vala @@ -0,0 +1,90 @@ +using GLib; +using Gee; + +public class Message : Object, Comparable, Hashable +{ + public string guid { get; set; default = ""; } + public string text { get; set; default = ""; } + public DateTime date { get; set; default = new DateTime.now_local(); } + public string sender { get; set; default = null; } + + public Attachment[] attachments { get; set; default = {}; } + + public bool should_animate = false; + + public bool from_me { + get { + // Hm, this may have been accidental. + return sender == "(Me)"; + } + } + + public bool is_attachment_marker { + get { + uint8[] attachment_marker_str = { 0xEF, 0xBF, 0xBC, 0x00 }; + if (text.length > attachment_marker_str.length) { + return false; + } + + return (string)attachment_marker_str == text; + } + } + + 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; + this.sender = sender; + } + + public Message.from_hash_table(HashTable message_data) { + guid = message_data["id"].get_string(); + text = message_data["text"].get_string(); + sender = message_data["sender"].get_string(); + date = new DateTime.from_unix_utc(message_data["date"].get_int64()); + + // Attachments + var attachments_variant = message_data["attachments"]; + var attachments = new Gee.ArrayList(); + if (attachments_variant != null) { + for (int i = 0; i < attachments_variant.n_children(); i++) { + var attachment_variant = attachments_variant.get_child_value(i); + var attachment = new Attachment.from_variant(attachment_variant); + attachments.add(attachment); + } + } + + this.attachments = attachments.to_array(); + } + + public int compare_to(Message other) { + if (guid == other.guid) { + return 0; + } + + return 1; + } + + public bool equal_to(Message other) { + return guid == other.guid; + } + + public uint hash() { + return guid.hash(); + } +} \ No newline at end of file diff --git a/gtk/src/resources/kordophone.gresource.xml b/gtk/src/resources/kordophone.gresource.xml new file mode 100644 index 0000000..43040ca --- /dev/null +++ b/gtk/src/resources/kordophone.gresource.xml @@ -0,0 +1,6 @@ + + + + style.css + + \ No newline at end of file diff --git a/gtk/src/resources/net.buzzert.kordophone.desktop b/gtk/src/resources/net.buzzert.kordophone.desktop new file mode 100644 index 0000000..025218d --- /dev/null +++ b/gtk/src/resources/net.buzzert.kordophone.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Encoding=UTF-8 +Name=Kordophone +Comment=Kordophone GTK Client +Exec=kordophone +Icon=net.buzzert.kordophone.png +Terminal=false + diff --git a/gtk/src/resources/net.buzzert.kordophone.png b/gtk/src/resources/net.buzzert.kordophone.png new file mode 100644 index 0000000..be1261d Binary files /dev/null and b/gtk/src/resources/net.buzzert.kordophone.png differ diff --git a/gtk/src/resources/style.css b/gtk/src/resources/style.css new file mode 100644 index 0000000..37c73bf --- /dev/null +++ b/gtk/src/resources/style.css @@ -0,0 +1,59 @@ +/* Kordophone application styles */ + +.conversation-row { + padding: 6px 10px; + border-bottom: 1px solid alpha(#000, 0.1); +} + +.conversation-row:selected { + background-color: alpha(@accent_bg_color, 0.50); +} + +.flipped-y-axis { + /* Invert the y-axis, so the messages are drawn bottom-to-top */ + /* Individual messages are drawn upside down in the custom renderer */ + transform: scale(1, -1); +} + +.transcript-drawing-area { + color: darker(@accent_bg_color); +} + +.message-input-box { + margin-bottom: 14px; + margin-top: 14px; + margin-left: 14px; + margin-right: 14px; +} + +.message-input-entry { + font-size: 1.1rem; + border-radius: 8px; + padding: 12px; + border: 1px solid alpha(@borders, 0.5); +} + +.attachment-preview-row { + background-color: alpha(@window_bg_color, 0.3); + border-radius: 8px; + padding: 8px; +} + +.attachment-preview { + border-radius: 8px; + border: 1px solid alpha(@borders, 0.5); +} + +.attachment-preview.completed { + border-color: @success_color; +} + +.attachment-image { + border-radius: 8px; +} + +.hovering-text-view { + background-color: transparent; + color: transparent; + line-height: 1.18; /* TextBubbleLayout.line_height */ +} diff --git a/gtk/src/service/dbus-service-base.vala b/gtk/src/service/dbus-service-base.vala new file mode 100644 index 0000000..cf8cd8a --- /dev/null +++ b/gtk/src/service/dbus-service-base.vala @@ -0,0 +1,12 @@ +public abstract class DBusServiceProxy : Object { + protected const string DBUS_PATH = "/net/buzzert/kordophonecd/daemon"; + protected const string DBUS_NAME = "net.buzzert.kordophonecd"; + + protected DBusServiceProxy() { + } +} + +protected errordomain DBusServiceProxyError { + NOT_CONNECTED, + PASSWORD_STORAGE; +} \ No newline at end of file diff --git a/gtk/src/service/interface/dbusservice.vala b/gtk/src/service/interface/dbusservice.vala new file mode 100644 index 0000000..b7b89ac --- /dev/null +++ b/gtk/src/service/interface/dbusservice.vala @@ -0,0 +1,87 @@ +/* Generated by vala-dbus-binding-tool 1.0-aa2fb. Do not modify! */ +/* Generated with: vala-dbus-binding-tool --no-synced --strip-namespace=net --strip-namespace=buzzert --rename-namespace=kordophone:DBusService --api-path=xml/ */ +using GLib; + +namespace DBusService { + + [DBus (name = "net.buzzert.kordophone.Settings", timeout = 120000)] + public interface Settings : GLib.Object { + + [DBus (name = "ServerURL")] + public abstract string server_u_r_l { owned get; set; } + + [DBus (name = "Username")] + public abstract string username { owned get; set; } + + [DBus (name = "SetServer")] + public abstract void set_server(string url, string user) throws DBusError, IOError; + + [DBus (name = "ConfigChanged")] + public signal void config_changed(); + } + + [DBus (name = "net.buzzert.kordophone.Repository", timeout = 120000)] + public interface Repository : GLib.Object { + + [DBus (name = "GetVersion")] + public abstract string get_version() throws DBusError, IOError; + + [DBus (name = "GetConversations")] + public abstract GLib.HashTable[] get_conversations(int limit, int offset) throws DBusError, IOError; + + [DBus (name = "SyncConversationList")] + public abstract void sync_conversation_list() throws DBusError, IOError; + + [DBus (name = "SyncAllConversations")] + public abstract void sync_all_conversations() throws DBusError, IOError; + + [DBus (name = "SyncConversation")] + public abstract void sync_conversation(string conversation_id) throws DBusError, IOError; + + [DBus (name = "MarkConversationAsRead")] + public abstract void mark_conversation_as_read(string conversation_id) throws DBusError, IOError; + + [DBus (name = "ConversationsUpdated")] + public signal void conversations_updated(); + + [DBus (name = "DeleteAllConversations")] + public abstract void delete_all_conversations() throws DBusError, IOError; + + [DBus (name = "GetMessages")] + public abstract GLib.HashTable[] get_messages(string conversation_id, string last_message_id) throws DBusError, IOError; + + [DBus (name = "SendMessage")] + public abstract string send_message(string conversation_id, string text, string[] attachment_guids) throws DBusError, IOError; + + [DBus (name = "MessagesUpdated")] + public signal void messages_updated(string conversation_id); + + [DBus (name = "UpdateStreamReconnected")] + public signal void update_stream_reconnected(); + + [DBus (name = "GetAttachmentInfo")] + public abstract RepositoryAttachmentInfoStruct get_attachment_info(string attachment_id) throws DBusError, IOError; + + [DBus (name = "DownloadAttachment")] + public abstract void download_attachment(string attachment_id, bool preview) throws DBusError, IOError; + + [DBus (name = "UploadAttachment")] + public abstract string upload_attachment(string path) throws DBusError, IOError; + + [DBus (name = "AttachmentDownloadCompleted")] + public signal void attachment_download_completed(string attachment_id); + + [DBus (name = "AttachmentDownloadFailed")] + public signal void attachment_download_failed(string attachment_id, string error_message); + + [DBus (name = "AttachmentUploadCompleted")] + public signal void attachment_upload_completed(string upload_guid, string attachment_guid); + } + + public struct RepositoryAttachmentInfoStruct { + public string attr1; + public string attr2; + public bool attr3; + public bool attr4; + } +} diff --git a/gtk/src/service/interface/generate.sh b/gtk/src/service/interface/generate.sh new file mode 100755 index 0000000..72bed52 --- /dev/null +++ b/gtk/src/service/interface/generate.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +if ! command -v vala-dbus-binding-tool >/dev/null 2>&1; then + echo "Error: vala-dbus-binding-tool not found. Please install it first." + echo "https://github.com/freesmartphone/vala-dbus-binding-tool" + exit 1 +fi + +vala-dbus-binding-tool --no-synced --strip-namespace=net --strip-namespace=buzzert --rename-namespace=kordophone:DBusService --api-path=xml/ + diff --git a/gtk/src/service/interface/xml/net.buzzert.kordophonecd.Server.xml b/gtk/src/service/interface/xml/net.buzzert.kordophonecd.Server.xml new file mode 100644 index 0000000..cdef983 --- /dev/null +++ b/gtk/src/service/interface/xml/net.buzzert.kordophonecd.Server.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gtk/src/service/repository.vala b/gtk/src/service/repository.vala new file mode 100644 index 0000000..7931404 --- /dev/null +++ b/gtk/src/service/repository.vala @@ -0,0 +1,147 @@ +using GLib; +using Gee; + +public class Repository : DBusServiceProxy { + public signal void conversations_updated(); + public signal void messages_updated(string conversation_guid); + public signal void attachment_downloaded(string attachment_guid); + public signal void attachment_uploaded(string upload_guid, string attachment_guid); + public signal void reconnected(); + + public static Repository get_instance() { + if (instance == null) { + instance = new Repository(); + } + + return instance; + } + + private static Repository instance = null; + private DBusService.Repository? dbus_repository; + private uint dbus_watch_id; + + private Repository() { + this.dbus_watch_id = Bus.watch_name(BusType.SESSION, DBUS_NAME, BusNameWatcherFlags.AUTO_START, (name, name_owner) => { + connect_to_repository(); + }); + } + + private void connect_to_repository() { + GLib.info("Connecting to repository"); + + try { + this.dbus_repository = Bus.get_proxy_sync(BusType.SESSION, DBUS_NAME, DBUS_PATH); + this.dbus_repository.conversations_updated.connect(() => { + conversations_updated(); + }); + + this.dbus_repository.messages_updated.connect((conversation_guid) => { + messages_updated(conversation_guid); + }); + + this.dbus_repository.attachment_download_completed.connect((attachment_guid) => { + attachment_downloaded(attachment_guid); + }); + + this.dbus_repository.attachment_upload_completed.connect((upload_guid, attachment_guid) => { + attachment_uploaded(upload_guid, attachment_guid); + }); + + this.dbus_repository.update_stream_reconnected.connect(() => { + reconnected(); + }); + + conversations_updated(); + reconnected(); + } catch (GLib.Error e) { + warning("Failed to connect to repository: %s", e.message); + } + } + + public void sync_conversation_list() throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + dbus_repository.sync_conversation_list(); + } + + public Conversation[] get_conversations(int limit = 200) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + var conversations = dbus_repository.get_conversations(limit, 0); + Conversation[] returned_conversations = new Conversation[conversations.length]; + + for (int i = 0; i < conversations.length; i++) { + returned_conversations[i] = new Conversation.from_hash_table(conversations[i]); + } + + return returned_conversations; + } + + public Message[] get_messages(string conversation_guid, string last_message_id = "") throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + var messages = dbus_repository.get_messages(conversation_guid, last_message_id); + Message[] returned_messages = new Message[messages.length]; + + for (int i = 0; i < messages.length; i++) { + returned_messages[i] = new Message.from_hash_table(messages[i]); + } + + return returned_messages; + } + + public string send_message(string conversation_guid, string message, string[] attachment_guids) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + return dbus_repository.send_message(conversation_guid, message, attachment_guids); + } + + public void sync_conversation(string conversation_guid) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + dbus_repository.sync_conversation(conversation_guid); + } + + public void mark_conversation_as_read(string conversation_guid) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + dbus_repository.mark_conversation_as_read(conversation_guid); + } + + public void download_attachment(string attachment_guid, bool preview) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + dbus_repository.download_attachment(attachment_guid, preview); + } + + public string upload_attachment(string filename) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + return dbus_repository.upload_attachment(filename); + } + + public AttachmentInfo get_attachment_info(string attachment_guid) throws DBusServiceProxyError, GLib.Error { + if (dbus_repository == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Repository not connected"); + } + + var info = dbus_repository.get_attachment_info(attachment_guid); + return new AttachmentInfo(info.attr1, info.attr2, info.attr3, info.attr4); + } +} diff --git a/gtk/src/service/settings.vala b/gtk/src/service/settings.vala new file mode 100644 index 0000000..7016e2a --- /dev/null +++ b/gtk/src/service/settings.vala @@ -0,0 +1,94 @@ +using GLib; + +public class Settings : DBusServiceProxy +{ + public signal void config_changed(); + public signal void settings_ready(); + + private DBusService.Settings? dbus_settings; + private Secret.Service secret_service; + + public Settings() { + base(); + + try { + secret_service = Secret.Service.get_sync(Secret.ServiceFlags.OPEN_SESSION); + + this.dbus_settings = Bus.get_proxy_sync(BusType.SESSION, DBUS_NAME, DBUS_PATH); + settings_ready(); + } catch (GLib.Error e) { + warning("Failed to get secret service: %s", e.message); + } + } + + public string get_server_url() throws DBusServiceProxyError, GLib.Error { + if (dbus_settings == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected"); + } + return dbus_settings.server_u_r_l; + } + + public void set_server_url(string url) throws Error, GLib.Error { + if (dbus_settings == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected"); + } + dbus_settings.server_u_r_l = url; + } + + public string get_username() throws Error, GLib.Error { + if (dbus_settings == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected"); + } + return dbus_settings.username; + } + + public void set_username(string username) throws Error, GLib.Error { + if (dbus_settings == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected"); + } + dbus_settings.username = username; + } + + public void set_server(string url, string username) throws Error, GLib.Error { + if (dbus_settings == null) { + throw new DBusServiceProxyError.NOT_CONNECTED("Settings not connected"); + } + dbus_settings.set_server(url, username); + } + + private HashTable password_attributes() { + var attributes = new HashTable(str_hash, str_equal); + attributes["service"] = "net.buzzert.kordophonecd"; + attributes["target"] = "default"; + attributes["username"] = get_username(); + return attributes; + } + + public string get_password() throws Error { + var attributes = password_attributes(); + var password = secret_service.lookup_sync(null, attributes, null); + if (password == null) { + warning("No password found for user %s", get_username()); + return ""; + } + + return password.get_text(); + } + + public void set_password(string password) throws Error, GLib.Error { + var attributes = password_attributes(); + bool result = secret_service.store_sync( + null, + attributes, + Secret.COLLECTION_DEFAULT, + "Kordophone Keystore", + new Secret.Value(password, password.length, "text/plain"), + null + ); + + if (!result) { + warning("Failed to store password for user %s", get_username()); + throw new DBusServiceProxyError.PASSWORD_STORAGE("Failed to store password"); + } + } +} \ No newline at end of file diff --git a/gtk/src/transcript/attachment-preview.vala b/gtk/src/transcript/attachment-preview.vala new file mode 100644 index 0000000..d9568af --- /dev/null +++ b/gtk/src/transcript/attachment-preview.vala @@ -0,0 +1,93 @@ +using Gtk; +using Gdk; + +class AttachmentPreview : Gtk.Box { + public signal void remove_requested(); + + private File file; + private string upload_guid; + private string? attachment_guid = null; + private bool is_completed = false; + + private Overlay overlay; + private Image picture; + private Spinner spinner; + private Button remove_button; + + public AttachmentPreview(File file, string upload_guid) { + Object(orientation: Orientation.VERTICAL, spacing: 0); + this.file = file; + this.upload_guid = upload_guid; + + setup_ui(); + load_image(); + } + + private void setup_ui() { + set_size_request(100, 100); + add_css_class("attachment-preview"); + + overlay = new Overlay(); + overlay.set_size_request(100, 100); + append(overlay); + + // Image preview + picture = new Image(); + overlay.set_child(picture); + + // Loading spinner + spinner = new Spinner(); + spinner.set_halign(Align.CENTER); + spinner.set_valign(Align.CENTER); + spinner.set_size_request(24, 24); + spinner.start(); + overlay.add_overlay(spinner); + + // Remove button + remove_button = new Button(); + remove_button.set_icon_name("window-close-symbolic"); + remove_button.add_css_class("circular"); + remove_button.add_css_class("destructive-action"); + remove_button.set_halign(Align.END); + remove_button.set_valign(Align.START); + remove_button.set_margin_top(4); + remove_button.set_margin_end(4); + remove_button.set_size_request(20, 20); + remove_button.clicked.connect(() => { + remove_requested(); + }); + overlay.add_overlay(remove_button); + } + + private void load_image() { + try { + picture.set_from_file(file.get_path()); + } catch (Error e) { + warning("Failed to load image preview: %s", e.message); + + // Show a placeholder icon if image loading fails + var icon = new Image.from_icon_name("image-x-generic"); + icon.set_pixel_size(48); + overlay.set_child(icon); + } + } + + public void set_completed(string attachment_guid) { + this.attachment_guid = attachment_guid; + this.is_completed = true; + + spinner.stop(); + spinner.set_visible(false); + + // Optionally change visual state to indicate completion + add_css_class("completed"); + } + + public string? get_attachment_guid() { + return attachment_guid; + } + + public bool get_is_completed() { + return is_completed; + } +} \ No newline at end of file diff --git a/gtk/src/transcript/layouts/bubble-layout.vala b/gtk/src/transcript/layouts/bubble-layout.vala new file mode 100644 index 0000000..1251a58 --- /dev/null +++ b/gtk/src/transcript/layouts/bubble-layout.vala @@ -0,0 +1,204 @@ +using Gtk; + +public struct BubbleLayoutConstants { + public float tail_width; + public float tail_curve_offset; + public float tail_side_offset; + public float tail_bottom_padding; + public float corner_radius; + public float text_x_padding; + public float text_y_padding; + + public BubbleLayoutConstants(float scale_factor) { + // TODO: Remove this, scale factor ignored for now. + scale_factor = 2.0f; + + tail_width = 15.0f / scale_factor; + tail_curve_offset = 2.5f / scale_factor; + tail_side_offset = 0.0f / scale_factor; + tail_bottom_padding = 4.0f / scale_factor; + corner_radius = 34.0f / scale_factor; + text_x_padding = 31.0f / scale_factor; + text_y_padding = 8.0f / scale_factor; + } +} + +public abstract class BubbleLayout : Object, ChatItemLayout +{ + public bool from_me { get; set; } + public float vertical_padding { get; set; } + public string id { get; set; } + + protected float max_width; + protected Widget parent; + protected BubbleLayoutConstants constants; + + protected BubbleLayout(Widget parent, float max_width) { + this.max_width = max_width; + this.parent = parent; + this.constants = BubbleLayoutConstants(parent.get_scale_factor()); + } + + private Gdk.RGBA background_color { + get { + return from_me ? parent.get_color() : Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 0.08f + }; + } + } + + public abstract float get_height(); + + public abstract float get_width(); + + public void draw(Snapshot snapshot) { + with_bubble_clip(snapshot, (snapshot) => { + draw_background(snapshot); + draw_content(snapshot); + }); + } + + public abstract void draw_content(Snapshot snapshot); + + public abstract void copy(Gdk.Clipboard clipboard); + + private void draw_background(Snapshot snapshot) + { + var width = get_width(); + var height = get_height(); + var color = background_color; + var bounds = Graphene.Rect().init(0, 0, width, height); + + const float gradient_darkening = 0.67f; + var start_point = Graphene.Point() { x = 0, y = 0 }; + var end_point = Graphene.Point() { x = 0, y = height }; + var stops = new Gsk.ColorStop[] { + Gsk.ColorStop() { offset = 0.0f, color = color }, + Gsk.ColorStop() { offset = 1.0f, color = Gdk.RGBA () { + red = color.red * gradient_darkening, + green = color.green * gradient_darkening, + blue = color.blue * gradient_darkening, + alpha = color.alpha + } }, + }; + + snapshot.append_linear_gradient(bounds, start_point, end_point, stops); + } + + protected void with_bubble_clip(Snapshot snapshot, Func func) { + var width = get_width(); + var height = get_height(); + + if (width > 10 && height > 10) { + var path = create_bubble_path(width, height, from_me); + snapshot.push_fill(path, Gsk.FillRule.WINDING); + func(snapshot); + snapshot.pop(); + } else { + func(snapshot); + } + } + + private Gsk.Path create_bubble_path(float width, float height, bool tail_on_right = false) + { + var builder = new Gsk.PathBuilder(); + + float bubble_width = width - constants.tail_width; + float bubble_height = height - constants.tail_bottom_padding; + + // Base position adjustments based on tail position + float x = tail_on_right ? 0.0f : constants.tail_width; + float y = 0.0f; + + // Calculate tail direction multiplier (-1 for left, 1 for right) + float dir = tail_on_right ? 1.0f : -1.0f; + + // Calculate tail side positions based on direction + float tail_side_x = tail_on_right ? (x + bubble_width) : x; + + // Start at top corner opposite to the tail + builder.move_to(tail_on_right ? (x + constants.corner_radius) : (x + bubble_width - constants.corner_radius), y); + + // Top edge + builder.line_to(tail_on_right ? (x + bubble_width - constants.corner_radius) : (x + constants.corner_radius), y); + + // Top corner on tail side + if (tail_on_right) { + builder.html_arc_to(x + bubble_width, y, + x + bubble_width, y + constants.corner_radius, + constants.corner_radius); + } else { + builder.html_arc_to(x, y, + x, y + constants.corner_radius, + constants.corner_radius); + } + + // Side edge on tail side + builder.line_to(tail_side_x, y + bubble_height - constants.corner_radius); + + // Corner with tail + float tail_tip_x = tail_side_x + (dir * (constants.tail_width - constants.tail_curve_offset)); + float tail_tip_y = y + bubble_height; + + // Control points for the bezier curve + float ctrl_point1_x = tail_side_x + (dir * constants.tail_side_offset); + float ctrl_point1_y = y + bubble_height - constants.corner_radius/3; + + float ctrl_point2_x = tail_side_x + (dir * (constants.tail_width / 2.0f)); + float ctrl_point2_y = y + bubble_height - 2; + + // Point where the tail meets the bottom edge + float tail_base_x = tail_side_x - (dir * constants.corner_radius/2); + float tail_base_y = y + bubble_height; + + // Draw the corner with tail using bezier curves + builder.cubic_to(ctrl_point1_x, ctrl_point1_y, + ctrl_point2_x, ctrl_point2_y, + tail_tip_x, tail_tip_y); + + builder.cubic_to(tail_tip_x - (dir * constants.tail_curve_offset), tail_tip_y + constants.tail_curve_offset, + tail_base_x + (dir * constants.tail_width), tail_base_y, + tail_base_x, tail_base_y); + + // Bottom edge + builder.line_to(tail_on_right ? (x + constants.corner_radius) : (x + bubble_width - constants.corner_radius), y + bubble_height); + + // Bottom corner opposite to tail + if (tail_on_right) { + builder.html_arc_to(x, y + bubble_height, + x, y + bubble_height - constants.corner_radius, + constants.corner_radius); + } else { + builder.html_arc_to(x + bubble_width, y + bubble_height, + x + bubble_width, y + bubble_height - constants.corner_radius, + constants.corner_radius); + } + + // Side edge opposite to tail + if (tail_on_right) { + builder.line_to(x, y + constants.corner_radius); + + // Top corner to close path + builder.html_arc_to(x, y, + x + constants.corner_radius, y, + constants.corner_radius); + } else { + builder.line_to(x + bubble_width, y + constants.corner_radius); + + // Top corner to close path + builder.html_arc_to(x + bubble_width, y, + x + bubble_width - constants.corner_radius, y, + constants.corner_radius); + } + + // Close the path + builder.close(); + + return builder.to_path(); + } +} + + diff --git a/gtk/src/transcript/layouts/chat-item-layout.vala b/gtk/src/transcript/layouts/chat-item-layout.vala new file mode 100644 index 0000000..bfbf712 --- /dev/null +++ b/gtk/src/transcript/layouts/chat-item-layout.vala @@ -0,0 +1,14 @@ +using Gtk; + +public interface ChatItemLayout : Object +{ + public abstract bool from_me { get; set; } + public abstract float vertical_padding { get; set; } + public abstract string id { get; set; } + + public abstract float get_height(); + public abstract float get_width(); + + public abstract void draw(Snapshot snapshot); + public abstract void copy(Gdk.Clipboard clipboard); +} diff --git a/gtk/src/transcript/layouts/date-item-layout.vala b/gtk/src/transcript/layouts/date-item-layout.vala new file mode 100644 index 0000000..b147cd0 --- /dev/null +++ b/gtk/src/transcript/layouts/date-item-layout.vala @@ -0,0 +1,54 @@ +using Gtk; + +class DateItemLayout : Object, ChatItemLayout { + public bool from_me { get; set; } + public float vertical_padding { get; set; } + public string id { get; set; } + + private Pango.Layout layout; + private float max_width; + + public DateItemLayout(string date_str, Widget parent, float max_width) { + this.max_width = max_width; + + layout = parent.create_pango_layout(date_str); + layout.set_font_description(Pango.FontDescription.from_string("Sans 9")); + layout.set_alignment(Pango.Alignment.CENTER); + } + + public float get_height() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.height + 50.0f; + } + + public float get_width() { + return max_width; + } + + public void draw(Snapshot snapshot) { + snapshot.save(); + + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + snapshot.translate(Graphene.Point() { + x = (max_width - logical_rect.width) / 2, + y = (get_height() - logical_rect.height) / 2 + }); + + snapshot.append_layout(layout, Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 0.5f + }); + + snapshot.restore(); + } + + public void copy(Gdk.Clipboard clipboard) { + clipboard.set_text(layout.get_text()); + } +} \ No newline at end of file diff --git a/gtk/src/transcript/layouts/image-bubble-layout.vala b/gtk/src/transcript/layouts/image-bubble-layout.vala new file mode 100644 index 0000000..c957d86 --- /dev/null +++ b/gtk/src/transcript/layouts/image-bubble-layout.vala @@ -0,0 +1,140 @@ +using Gee; +using Gtk; + +private class SizeCache +{ + private static SizeCache instance = null; + private HashMap size_cache = new HashMap(); + + public static SizeCache get_instance() { + if (instance == null) { + instance = new SizeCache(); + } + return instance; + } + + public Graphene.Size? get_size(string image_path) { + return size_cache.get(image_path); + } + + public void set_size(string image_path, Graphene.Size size) { + size_cache.set(image_path, size); + } +} + +private class ImageBubbleLayout : BubbleLayout +{ + public string image_path; + public bool is_downloaded; + public string? attachment_guid; + + private Graphene.Size image_size; + private Gdk.Texture? cached_texture = null; + + public ImageBubbleLayout(string image_path, bool from_me, Widget parent, float max_width, Graphene.Size? image_size = null) { + base(parent, max_width); + + this.from_me = from_me; + this.image_path = image_path; + this.is_downloaded = false; + + // Calculate image dimensions for layout + calculate_image_dimensions(image_size); + } + + private void calculate_image_dimensions(Graphene.Size? image_size) { + if (image_size != null) { + this.image_size = image_size; + return; + } + + var cached_size = SizeCache.get_instance().get_size(image_path); + if (cached_size != null) { + this.image_size = cached_size; + return; + } + + // Try to load the image to get its dimensions + try { + warning("No image size provided, loading image to get dimensions"); + + var texture = Gdk.Texture.from_filename(image_path); + var original_width = (float)texture.get_width(); + var original_height = (float)texture.get_height(); + + this.image_size = Graphene.Size() { width = original_width, height = original_height }; + SizeCache.get_instance().set_size(image_path, this.image_size); + } catch (Error e) { + // Fallback dimensions if image can't be loaded + warning("Failed to load image %s: %s", image_path, e.message); + this.image_size = Graphene.Size() { width = 200.0f, height = 150.0f }; + } + } + + private void load_image_if_needed() { + if (cached_texture != null) { + return; + } + + if (!is_downloaded) { + return; + } + + try { + cached_texture = Gdk.Texture.from_filename(image_path); + } catch (Error e) { + warning("Failed to load image %s: %s", image_path, e.message); + } + } + + private float intrinsic_height { + get { + var scale_factor = float.min(max_width / image_size.width, 1.0f); + return image_size.height * scale_factor; + } + } + + private float intrinsic_width { + get { + var scale_factor = float.min(max_width / image_size.width, 1.0f); + return image_size.width * scale_factor; + } + } + + public override float get_height() { + return float.max(intrinsic_height, 100.0f); + } + + public override float get_width() { + return float.max(intrinsic_width, 200.0f); + } + + public override void draw_content(Snapshot snapshot) { + load_image_if_needed(); + + snapshot.save(); + + var image_rect = Graphene.Rect () { + origin = Graphene.Point() { x = 0, y = 0 }, + size = Graphene.Size() { width = intrinsic_width, height = intrinsic_height } + }; + + // Center image in the bubble (if it's smaller than the bubble) + snapshot.translate(Graphene.Point() { + x = (get_width() - intrinsic_width) / 2, + y = (get_height() - intrinsic_height) / 2 + }); + + if (cached_texture != null) { + snapshot.append_texture(cached_texture, image_rect); + } else { + snapshot.append_color(Gdk.RGBA() { red = 0.6f, green = 0.6f, blue = 0.6f, alpha = 0.5f }, image_rect); + } + + snapshot.restore(); + } + + public override void copy(Gdk.Clipboard clipboard) { + clipboard.set_texture(cached_texture); + } +} \ No newline at end of file diff --git a/gtk/src/transcript/layouts/sender-annotation-layout.vala b/gtk/src/transcript/layouts/sender-annotation-layout.vala new file mode 100644 index 0000000..13f6fe6 --- /dev/null +++ b/gtk/src/transcript/layouts/sender-annotation-layout.vala @@ -0,0 +1,61 @@ +using Gtk; + +private class SenderAnnotationLayout : Object, ChatItemLayout +{ + public string sender; + public float max_width; + public bool from_me { get; set; default = false; } + public float vertical_padding { get; set; default = 0.0f; } + public string id { get; set; } + + private Pango.Layout layout; + private BubbleLayoutConstants constants; + private const float padding = 10.0f; + + public SenderAnnotationLayout(string sender, float max_width, Widget parent) { + this.sender = sender; + this.max_width = max_width; + this.constants = BubbleLayoutConstants(parent.get_scale_factor()); + + layout = parent.create_pango_layout(sender); + var font_desc = Pango.FontDescription.from_string("Sans 8"); + layout.set_font_description(font_desc); + } + + public float get_height() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.height + padding; + } + + public float get_width() { + return max_width; + } + + public void draw(Snapshot snapshot) { + snapshot.save(); + + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + + snapshot.translate(Graphene.Point() { + x = constants.text_x_padding + constants.tail_width, + y = get_height() - logical_rect.height, + }); + + snapshot.append_layout(layout, Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 0.8f + }); + + snapshot.restore(); + } + + public void copy(Gdk.Clipboard clipboard) { + clipboard.set_text(sender); + } +} \ No newline at end of file diff --git a/gtk/src/transcript/layouts/text-bubble-layout.vala b/gtk/src/transcript/layouts/text-bubble-layout.vala new file mode 100644 index 0000000..0ab77e5 --- /dev/null +++ b/gtk/src/transcript/layouts/text-bubble-layout.vala @@ -0,0 +1,113 @@ +using Gtk; + +public class TextBubbleLayout : BubbleLayout +{ + public Message message; + private Pango.Layout layout; + + public static float line_height { get { return 1.18f; } } + + public static Pango.FontDescription body_font { + owned get { + var settings = Gtk.Settings.get_default(); + var font_name = settings.gtk_font_name; + + // Create font description from system font + var font_desc = Pango.FontDescription.from_string(font_name); + return font_desc; + } + } + + public TextBubbleLayout(Message message, Widget parent, float max_width) { + base(parent, max_width); + + this.from_me = message.from_me; + this.message = message; + + layout = parent.create_pango_layout(null); + layout.set_markup(message.markup, -1); + + var font_desc = TextBubbleLayout.body_font; + layout.set_font_description(font_desc); + layout.set_wrap(Pango.WrapMode.WORD_CHAR); + layout.set_line_spacing(line_height); + + // Set max width + layout.set_width((int)text_available_width * Pango.SCALE); + } + + private float text_available_width { + get { + return max_width - text_x_offset - constants.text_x_padding; + } + } + + private float text_x_offset { + get { + return from_me ? constants.text_x_padding : constants.tail_width + constants.text_x_padding; + } + } + + private float text_x_padding { + get { + // Opposite of text_x_offset + return from_me ? constants.tail_width + constants.text_x_padding : constants.text_x_padding; + } + } + + private Gdk.RGBA background_color { + get { + return from_me ? parent.get_color() : Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 0.08f + }; + } + } + + public override float get_height() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.height + constants.corner_radius + constants.tail_bottom_padding + constants.text_y_padding; + } + + public override float get_width() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return logical_rect.width + text_x_offset + text_x_padding; + } + + public Graphene.Point get_text_origin() { + Pango.Rectangle ink_rect, logical_rect; + layout.get_pixel_extents(out ink_rect, out logical_rect); + + return Graphene.Point() { + x = text_x_offset, + y = ((get_height() - constants.tail_bottom_padding) - logical_rect.height) / 2 + }; + } + + public override void draw_content(Snapshot snapshot) { + snapshot.save(); + + snapshot.translate(get_text_origin()); + + snapshot.append_layout(layout, Gdk.RGBA() { + red = 1.0f, + green = 1.0f, + blue = 1.0f, + alpha = 1.0f + }); + + snapshot.restore(); + } + + public override void copy(Gdk.Clipboard clipboard) { + clipboard.set_text(message.text); + } +} + + diff --git a/gtk/src/transcript/message-list-model.vala b/gtk/src/transcript/message-list-model.vala new file mode 100644 index 0000000..b4cf6ec --- /dev/null +++ b/gtk/src/transcript/message-list-model.vala @@ -0,0 +1,137 @@ +using GLib; +using Gee; + +public class MessageListModel : Object, ListModel +{ + public signal void messages_changed(); + + public ArrayList messages { + get { return _messages; } + } + + public bool is_group_chat { + get { + return participants.size > 2; + } + } + + public Conversation conversation { get; private set; } + + private ArrayList _messages; + private HashSet participants = new HashSet(); + private ulong update_handler_id = 0; + private ulong reconnected_handler_id = 0; + + public MessageListModel(Conversation conversation) { + _messages = new ArrayList(); + this.conversation = conversation; + } + + ~MessageListModel() { + // NOTE: this won't actually get destructed automatically because of a retain cycle with the signal handler. + // unwatch_updates() should be called explicitly when the model is no longer needed. + unwatch_updates(); + } + + public void watch_updates() { + if (this.update_handler_id == 0) { + weak MessageListModel self = this; + this.update_handler_id = Repository.get_instance().messages_updated.connect((conversation_guid) => { + self.got_messages_updated(conversation_guid); + }); + } + + if (this.reconnected_handler_id == 0) { + weak MessageListModel self = this; + this.reconnected_handler_id = Repository.get_instance().reconnected.connect(() => { + // On reconnect, reload the messages that we're looking at now. + self.load_messages(); + }); + } + } + + public void unwatch_updates() { + if (this.update_handler_id != 0) { + Repository.get_instance().disconnect(this.update_handler_id); + this.update_handler_id = 0; + } + + if (this.reconnected_handler_id != 0) { + Repository.get_instance().disconnect(this.reconnected_handler_id); + this.reconnected_handler_id = 0; + } + } + + public void load_messages() { + var previous_messages = new HashSet(); + previous_messages.add_all(_messages); + + try { + bool first_load = _messages.size == 0; + + Message[] messages = Repository.get_instance().get_messages(conversation.guid); + + // Clear existing set + uint old_count = _messages.size; + _messages.clear(); + participants.clear(); + + // Notify of removal + if (old_count > 0) { + items_changed(0, old_count, 0); + } + + // Process each conversation + uint position = 0; + + for (int i = 0; i < messages.length; i++) { + var message = messages[i]; + participants.add(message.sender); + + if (!first_load && !previous_messages.contains(message)) { + // This is a new message according to the UI, schedule an animation for it. + message.should_animate = true; + } + + _messages.add(message); + position++; + } + + // Notify of additions + if (position > 0) { + items_changed(0, 0, position); + } + } catch (Error e) { + warning("Failed to load messages: %s", e.message); + } + + messages_changed(); + } + + public void mark_as_read() { + try { + Repository.get_instance().mark_conversation_as_read(conversation.guid); + } catch (Error e) { + warning("Failed to mark conversation as read: %s", e.message); + } + } + + private void got_messages_updated(string conversation_guid) { + if (conversation_guid == this.conversation.guid) { + load_messages(); + } + } + + // ListModel implementation + public Type get_item_type() { + return typeof(Message); + } + + public uint get_n_items() { + return _messages.size; + } + + public Object? get_item(uint position) { + return _messages.get((int)position); + } +} \ No newline at end of file diff --git a/gtk/src/transcript/transcript-container-view.vala b/gtk/src/transcript/transcript-container-view.vala new file mode 100644 index 0000000..4d1bef6 --- /dev/null +++ b/gtk/src/transcript/transcript-container-view.vala @@ -0,0 +1,336 @@ +using Gtk; +using Adw; +using Gee; +using Gdk; +using GLib; + +class TranscriptContainerView : Adw.Bin +{ + public TranscriptView transcript_view; + + private Box container; + private Button send_button; + private FlowBox attachment_flow_box; + + private TextView message_view; + private TextBuffer message_buffer; + private HashSet pending_uploads; + private HashMap attachment_previews; + private ArrayList completed_attachments; + + public string message_body { + owned get { + TextIter start_iter, end_iter; + message_buffer.get_bounds(out start_iter, out end_iter); + return message_buffer.get_text(start_iter, end_iter, false); + } + } + + public ArrayList attachment_guids { + owned get { + var attachment_guids = new ArrayList(); + completed_attachments.foreach((attachment) => { + attachment_guids.add(attachment.attachment_guid); + return true; + }); + + return attachment_guids; + } + } + + private bool can_send { + get { + return (message_body.length > 0 || completed_attachments.size > 0) && pending_uploads.size == 0; + } + } + + public TranscriptContainerView () { + container = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); + set_child (container); + + pending_uploads = new HashSet(); + attachment_previews = new HashMap(); + completed_attachments = new ArrayList(); + + // Create message list view + transcript_view = new TranscriptView(); + transcript_view.set_vexpand(true); + container.append(transcript_view); + + // Create attachment preview row (initially hidden) + setup_attachment_row(); + + // Create bottom box for input + var input_box = new Box(Orientation.HORIZONTAL, 6); + input_box.add_css_class("message-input-box"); + input_box.set_valign(Align.END); + input_box.set_spacing(6); + container.append(input_box); + + // Setup drag and drop + setup_drag_and_drop(); + + // Connect to repository signals + Repository.get_instance().attachment_uploaded.connect(on_attachment_uploaded); + + // Create attach button (paperclip) + var attach_button = new Button.from_icon_name("mail-attachment"); + attach_button.set_tooltip_text("Attach file…"); + attach_button.add_css_class("flat"); + attach_button.clicked.connect(on_attach_button_clicked); + input_box.append(attach_button); + + // Create message text view (added after attachment button so button stays to the left) + message_buffer = new TextBuffer(null); + message_view = new TextView.with_buffer(message_buffer); + message_view.add_css_class("message-input-entry"); + message_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR); + message_view.set_hexpand(true); + message_view.set_vexpand(false); + message_view.set_size_request(-1, 12); // intrinsic + message_buffer.changed.connect(on_text_changed); + + // Key controller for sending on Enter (Shift+Enter for newline) + var send_key_ctrl = new EventControllerKey(); + send_key_ctrl.key_pressed.connect((keyval, keycode, state) => { + if (keyval == Gdk.Key.Return && (state & Gdk.ModifierType.SHIFT_MASK) == 0) { + on_request_send(); + return true; // consume + } + return false; + }); + message_view.add_controller(send_key_ctrl); + + // Handle paste events to detect images + message_view.paste_clipboard.connect(on_message_paste_clipboard); + + input_box.append(message_view); + + // Create send button + send_button = new Button(); + send_button.set_label("Send"); + send_button.set_sensitive(false); + send_button.add_css_class("suggested-action"); + send_button.clicked.connect(on_request_send); + input_box.append(send_button); + } + + private void setup_attachment_row() { + attachment_flow_box = new FlowBox(); + attachment_flow_box.set_max_children_per_line(6); + attachment_flow_box.set_row_spacing(6); + attachment_flow_box.set_column_spacing(6); + attachment_flow_box.halign = Align.START; + attachment_flow_box.add_css_class("attachment-preview-row"); + container.append(attachment_flow_box); + } + + private void setup_drag_and_drop() { + var drop_target = new DropTarget(typeof(File), Gdk.DragAction.COPY); + drop_target.drop.connect(on_file_dropped); + this.add_controller(drop_target); + } + + private bool on_file_dropped(Value val, double x, double y) { + if (!val.holds(typeof(File))) { + return false; + } + + var file = (File)val.get_object(); + if (file == null) { + return false; + } + + // Check if it's an image file + try { + var file_info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, FileQueryInfoFlags.NONE); + string content_type = file_info.get_content_type(); + + if (!content_type.has_prefix("image/")) { + return false; + } + + upload_file(file); + return true; + } catch (Error e) { + warning("Failed to get file info: %s", e.message); + return false; + } + } + + private void upload_file(File file) { + try { + string upload_guid = Repository.get_instance().upload_attachment(file.get_path()); + pending_uploads.add(upload_guid); + + var preview = new AttachmentPreview(file, upload_guid); + preview.remove_requested.connect(() => { + remove_attachment(upload_guid); + }); + + attachment_previews[upload_guid] = preview; + attachment_flow_box.append(preview); + + update_attachment_row_visibility(); + } catch (Error e) { + warning("Failed to upload attachment: %s", e.message); + } + } + + private void on_attachment_uploaded(string upload_guid, string attachment_guid) { + if (attachment_previews.has_key(upload_guid)) { + var preview = attachment_previews[upload_guid]; + preview.set_completed(attachment_guid); + completed_attachments.add(new UploadedAttachment(upload_guid, attachment_guid)); + pending_uploads.remove(upload_guid); + update_send_button_sensitivity(); + } + } + + private void remove_attachment(string upload_guid) { + if (attachment_previews.has_key(upload_guid)) { + var preview = attachment_previews[upload_guid]; + attachment_flow_box.remove(preview); + attachment_previews.unset(upload_guid); + + completed_attachments.foreach((attachment) => { + if (attachment.upload_guid == upload_guid) { + completed_attachments.remove(attachment); + return false; + } + + return true; + }); + + pending_uploads.remove(upload_guid); + + update_attachment_row_visibility(); + update_send_button_sensitivity(); + } + } + + private void update_attachment_row_visibility() { + bool has_attachments = attachment_previews.size > 0; + attachment_flow_box.set_visible(has_attachments); + } + + private void on_text_changed() { + update_send_button_sensitivity(); + } + + private void update_send_button_sensitivity() { + send_button.set_sensitive(can_send); + } + + private void on_request_send() { + if (can_send) { + on_send(); + + // Clear the message text + message_buffer.set_text(""); + + // Clear the attachment previews + attachment_flow_box.remove_all(); + attachment_previews.clear(); + completed_attachments.clear(); + pending_uploads.clear(); + + update_send_button_sensitivity(); + } + } + + private void on_send() { + var body = message_body; + + // Strip empty space at the beginning and end of the body + body = body.strip(); + + if (transcript_view.model == null) { + GLib.warning("No conversation selected"); + return; + } + + var selected_conversation = transcript_view.model.conversation; + if (selected_conversation == null) { + GLib.warning("No conversation selected"); + return; + } + + try { + Repository.get_instance().send_message(selected_conversation.guid, body, attachment_guids.to_array()); + } catch (Error e) { + GLib.warning("Failed to send message: %s", e.message); + } + } + + private void on_attach_button_clicked() { + var dialog = new Gtk.FileDialog(); + dialog.set_title("Select attachment"); + dialog.set_accept_label("Attach"); + dialog.set_modal(true); + + // Images only for now + var filter = new Gtk.FileFilter(); + filter.set_filter_name("Images"); + filter.add_mime_type("image/png"); + filter.add_mime_type("image/jpeg"); + filter.add_mime_type("image/gif"); + filter.add_mime_type("image/bmp"); + filter.add_mime_type("image/webp"); + filter.add_mime_type("image/svg+xml"); + filter.add_mime_type("image/tiff"); + + dialog.set_default_filter(filter); + + var parent_window = get_root() as Gtk.Window; + dialog.open.begin(parent_window, null, (obj, res) => { + try { + var file = dialog.open.end(res); + if (file != null) { + upload_file(file); + } + } catch (Error e) { + warning("Failed to open file dialog: %s", e.message); + } + }); + } + + private void on_message_paste_clipboard() { + var display = get_display(); + if (display == null) { + return; + } + + var clipboard = display.get_clipboard(); + if (clipboard == null) { + return; + } + + clipboard.read_texture_async.begin(null, (obj, res) => { + try { + var clip = obj as Gdk.Clipboard; + var texture = clip.read_texture_async.end(res); + if (texture != null) { + string tmp_path = Path.build_filename(Environment.get_tmp_dir(), "clipboard-" + Uuid.string_random() + ".png"); + texture.save_to_png(tmp_path); + var tmp_file = File.new_for_path(tmp_path); + upload_file(tmp_file); + } + } catch (Error e) { + // Ignore if clipboard does not contain image + } + }); + } +} + +class UploadedAttachment +{ + public string upload_guid; + public string attachment_guid; + + public UploadedAttachment(string upload_guid, string attachment_guid) + { + this.upload_guid = upload_guid; + this.attachment_guid = attachment_guid; + } +} \ No newline at end of file diff --git a/gtk/src/transcript/transcript-drawing-area.vala b/gtk/src/transcript/transcript-drawing-area.vala new file mode 100644 index 0000000..2417f23 --- /dev/null +++ b/gtk/src/transcript/transcript-drawing-area.vala @@ -0,0 +1,450 @@ +using Gtk; +using Gee; +using Gdk; + +private class TranscriptDrawingArea : Widget +{ + public bool show_sender = true; + public Adjustment? viewport { + get { + return _viewport; + } + + set { + _viewport = value; + queue_draw(); + } + } + + public signal void on_text_bubble_hover(VisibleLayout? text_bubble); + public signal void on_text_bubble_click(VisibleLayout? text_bubble); + public signal void on_image_bubble_activate(string attachment_guid); + + private ArrayList _messages = new ArrayList(); + private ArrayList _chat_items = new ArrayList(); + + private Adjustment? _viewport = null; + private const float bubble_margin = 18.0f; + + private GestureClick _click_gesture = new GestureClick(); + private Gdk.Rectangle? _click_bounding_box = null; + + private EventControllerMotion _motion_controller = new EventControllerMotion(); + private ArrayList _visible_text_layouts = new ArrayList(); + + private const bool debug_viewport = false; + private uint? _tick_callback_id = null; + private HashMap _animations = new HashMap(); + + public TranscriptDrawingArea() { + add_css_class("transcript-drawing-area"); + + weak TranscriptDrawingArea self = this; + + _click_gesture.button = 0; + _click_gesture.pressed.connect((n_press, x, y) => { + self.on_click(self._click_gesture.get_current_button(), n_press); + }); + add_controller(_click_gesture); + + _motion_controller.motion.connect((x, y) => { + if (self == null) { + return; + } + + // Only print motion events if window is active/focused + var window = self.get_native() as Gtk.Window; + if (window == null || !window.is_active) { + return; + } + + self.on_mouse_motion(x, y); + }); + _motion_controller.set_propagation_phase(PropagationPhase.CAPTURE); + add_controller(_motion_controller); + + SimpleAction copy_action = new SimpleAction("copy", null); + copy_action.activate.connect(() => { + if (_click_bounding_box != null) { + copy_message_at(_click_bounding_box); + } else { + GLib.warning("Failed to get bounding box for right click"); + } + }); + + SimpleActionGroup action_group = new SimpleActionGroup(); + action_group.add_action(copy_action); + + insert_action_group("transcript", action_group); + } + + public void set_messages(ArrayList messages) { + _messages = messages; + recompute_message_layouts(); + } + + public override SizeRequestMode get_request_mode() { + return SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) + { + if (orientation == Orientation.HORIZONTAL) { + // Horizontal, so we take up the full width provided + minimum = 0; + natural = for_size; + } else { + // compute total message layout height + float total_height = 0.0f; + _chat_items.foreach((chat_item) => { + total_height += chat_item.get_height() + chat_item.vertical_padding; + return true; + }); + + minimum = (int)total_height; + natural = (int)total_height; + } + + minimum_baseline = -1; + natural_baseline = -1; + } + + public override void size_allocate(int width, int height, int baseline) { + base.size_allocate(width, height, baseline); + recompute_message_layouts(); + } + + public override void snapshot(Gtk.Snapshot snapshot) { + const int viewport_y_padding = 50; + var viewport_rect = Graphene.Rect() { + origin = Graphene.Point() { x = 0, y = ((int)viewport.value - viewport_y_padding) }, + size = Graphene.Size() { width = get_width(), height = (int)viewport.page_size + (viewport_y_padding * 2) } + }; + + if (debug_viewport) { + // Draw viewport outline for debugging + var color = Gdk.RGBA() { red = 1.0f, green = 0, blue = 0, alpha = 1.0f }; + snapshot.append_border( + Gsk.RoundedRect().init_from_rect(viewport_rect, 0), + { 1, 1, 1, 1 }, + { color, color, color, color } + ); + } + + // Draw each item in reverse order, since the messages are in reverse order + float y_offset = 0; + int container_width = get_width(); + _visible_text_layouts.clear(); + for (int i = _chat_items.size - 1; i >= 0; i--) { + var chat_item = _chat_items[i]; + var item_width = chat_item.get_width(); + var item_height = chat_item.get_height(); + + var origin = Graphene.Point() { + x = (chat_item.from_me ? (container_width - item_width - bubble_margin) : bubble_margin), + y = y_offset + }; + + var size = Graphene.Size() { + width = item_width, + height = item_height + }; + + var rect = Graphene.Rect() { + origin = origin, + size = size + }; + + // Skip drawing if this item is not in the viewport + float height_offset = 0.0f; + if (viewport_rect.intersection(rect, null)) { + if (chat_item is BubbleLayout) { + _visible_text_layouts.add(VisibleLayout(chat_item as BubbleLayout, rect)); + } + + snapshot.save(); + + var pushed_opacity = false; + if (_animations.has_key(chat_item.id)) { + var animation = _animations[chat_item.id]; + + var item_height_offset = (float) (-item_height * (1.0 - animation.progress)); + snapshot.translate(Graphene.Point() { x = 0, y = item_height_offset }); + height_offset = item_height_offset; + + snapshot.push_opacity(animation.progress); + pushed_opacity = true; + } + + // Translate to the correct position + snapshot.translate(origin); + + // Flip the y-axis, since our parent is upside down (so newest messages are at the bottom) + snapshot.scale(1.0f, -1.0f); + + // Undo the y-axis flip, origin is top left + snapshot.translate(Graphene.Point() { x = 0, y = -item_height }); + + chat_item.draw(snapshot); + + if (pushed_opacity) { + snapshot.pop(); + } + + snapshot.restore(); + } + + y_offset += item_height + chat_item.vertical_padding + height_offset; + } + + animation_tick(); + } + + private VisibleLayout? get_visible_layout_at(double x, double y) { + var point = Graphene.Point() { x = (float)x, y = (float)y }; + foreach (var layout in _visible_text_layouts) { + if (layout.rect.contains_point(point)) { + return layout; + } + } + + return null; + } + + private VisibleLayout? get_text_bubble_at(double x, double y) { + var layout = get_visible_layout_at(x, y); + if (layout != null && layout.bubble is TextBubbleLayout) { + return layout; + } + + return null; + } + + private void on_mouse_motion(double x, double y) { + VisibleLayout? hovered_text_bubble = get_text_bubble_at(x, y); + on_text_bubble_hover(hovered_text_bubble); + } + + private void on_click(uint button, int n_press) { + if (button == Gdk.BUTTON_SECONDARY) { + on_right_click(); + } else if (button == Gdk.BUTTON_PRIMARY) { + on_left_click(); + + if (n_press == 2) { + on_double_click(); + } + } + } + + private void on_left_click() { + Gdk.Rectangle? bounding_box = null; + if (_click_gesture.get_bounding_box(out bounding_box)) { + var text_bubble = get_text_bubble_at(bounding_box.x, bounding_box.y); + on_text_bubble_click(text_bubble); + } + } + + private void on_double_click() { + Gdk.Rectangle? bounding_box = null; + if (_click_gesture.get_bounding_box(out bounding_box)) { + var double_clicked_bubble = get_visible_layout_at(bounding_box.x, bounding_box.y); + if (double_clicked_bubble != null && double_clicked_bubble.bubble is ImageBubbleLayout) { + var image_bubble = double_clicked_bubble.bubble as ImageBubbleLayout; + on_image_bubble_activate(image_bubble.attachment_guid); + } + } + } + + private void on_right_click() { + var menu_model = new Menu(); + menu_model.append("Copy", "transcript.copy"); + + Gdk.Rectangle? bounding_box = null; + if (_click_gesture.get_bounding_box(out bounding_box)) { + _click_bounding_box = bounding_box; + + var menu = new PopoverMenu.from_model(menu_model); + menu.set_position(PositionType.TOP); + menu.pointing_to = bounding_box; + menu.set_parent(this); + menu.popup(); + } + } + + private void copy_message_at(Gdk.Rectangle bounding_box) { + double y_offset = 0.0; + for (int i = _chat_items.size - 1; i >= 0; i--) { + var chat_item = _chat_items[i]; + y_offset += chat_item.get_height() + chat_item.vertical_padding; + + if (y_offset > bounding_box.y) { + chat_item.copy(get_clipboard()); + break; + } + } + } + + private bool animation_tick() { + HashSet animations_to_remove = new HashSet(); + _animations.foreach(entry => { + var animation = entry.value; + animation.tick_animation(); + + if ((animation.progress - 1.0).abs() < 0.000001) { + animations_to_remove.add(entry.key); + } + + return true; + }); + + foreach (var key in animations_to_remove) { + _animations.unset(key); + } + + if (_animations.size > 0) { + queue_draw(); + } + + return _animations.size > 0; + } + + private bool on_frame_clock_tick(Gtk.Widget widget, Gdk.FrameClock frame_clock) { + return animation_tick(); + } + + private void start_animation(string id) { + if (_animations.has_key(id)) { + return; + } + + var animation = new ChatItemAnimation(get_frame_clock()); + animation.start_animation(0.18f); + _animations.set(id, animation); + + _tick_callback_id = add_tick_callback(on_frame_clock_tick); + } + + private void recompute_message_layouts() { + var container_width = get_width(); + float max_width = container_width * 0.90f; + + DateTime? last_date = null; + string? last_sender = null; + ArrayList items = new ArrayList(); + _messages.foreach((message) => { + // Date Annotation + DateTime date = message.date; + if (last_date != null && date.difference(last_date) > (TimeSpan.HOUR * 1)) { + var date_item = new DateItemLayout(date.to_local().format("%b %d, %Y at %H:%M"), this, max_width); + items.add(date_item); + last_date = date; + last_sender = null; + } + + // Sender Annotation + if (show_sender && !message.from_me && last_sender != message.sender) { + var sender_bubble = new SenderAnnotationLayout(message.sender, max_width, this); + items.add(sender_bubble); + } + + // Text Bubble + var animate = message.should_animate; + if (message.text.length > 0 && !message.is_attachment_marker) { + var text_bubble = new TextBubbleLayout(message, this, max_width); + text_bubble.id = @"text-$(message.guid)"; + if (animate) { + start_animation(text_bubble.id); + } + + text_bubble.vertical_padding = (last_sender == message.sender) ? 4.0f : 10.0f; + items.add(text_bubble); + } + + // Check for attachments. For each one, add an image layout bubble + foreach (var attachment in message.attachments) { + Graphene.Size? image_size = null; + if (attachment.metadata != null) { + image_size = Graphene.Size() { + width = attachment.metadata.attribution_info.width, + height = attachment.metadata.attribution_info.height + }; + } + + var image_layout = new ImageBubbleLayout(attachment.preview_path, message.from_me, this, max_width, image_size); + image_layout.id = @"image-$(attachment.guid)"; + image_layout.attachment_guid = attachment.guid; + + if (animate) { + start_animation(image_layout.id); + } + + image_layout.is_downloaded = attachment.preview_downloaded; + items.add(image_layout); + + // If the attachment isn't downloaded, queue a download since we are going to be showing it here. + // TODO: Probably would be better if we only did this for stuff in the viewport. + if (!attachment.preview_downloaded) { + try { + Repository.get_instance().download_attachment(attachment.guid, true); + } catch (GLib.Error e) { + warning("Wasn't able to message daemon about queuing attachment download: %s", e.message); + } + } + } + + last_sender = message.sender; + last_date = date; + + return true; + }); + + _chat_items.clear(); + _chat_items.add_all(items); + + queue_draw(); + queue_resize(); + } +} + +private class ChatItemAnimation +{ + public double progress = 0.0; + private int64 start_time = 0; + private double duration = 0.0; + private Gdk.FrameClock frame_clock = null; + + delegate double EaseFunction(double t); + private EaseFunction ease_function = ease_out_quart; + + public ChatItemAnimation(Gdk.FrameClock frame_clock) { + this.frame_clock = frame_clock; + progress = 0.0f; + } + + public void start_animation(double duration) { + this.duration = duration; + this.progress = 0.0; + this.start_time = frame_clock.get_frame_time(); + } + + public void tick_animation() { + double t = (frame_clock.get_frame_time() - start_time) / (duration * 1000000.0); + t = double.min(1.0, double.max(0.0, t)); + progress = ease_function(t); + } + + private static double ease_out_quart(double t) { + return 1.0 - Math.pow(1.0 - t, 4); + } +} + +public struct VisibleLayout { + public weak BubbleLayout bubble; + public Graphene.Rect rect; + + public VisibleLayout(BubbleLayout bubble, Graphene.Rect rect) { + this.bubble = bubble; + this.rect = rect; + } +} \ No newline at end of file diff --git a/gtk/src/transcript/transcript-view.vala b/gtk/src/transcript/transcript-view.vala new file mode 100644 index 0000000..cd431bc --- /dev/null +++ b/gtk/src/transcript/transcript-view.vala @@ -0,0 +1,280 @@ +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); + } +}