diff --git a/src/conversation-list-model.vala b/src/conversation-list-model.vala new file mode 100644 index 0000000..f16cbec --- /dev/null +++ b/src/conversation-list-model.vala @@ -0,0 +1,118 @@ +using GLib; +using Gee; + +public class ConversationListModel : Object, ListModel +{ + public SortedSet conversations { + owned get { return _conversations.read_only_view; } + } + + private SortedSet _conversations; + private RepositoryService repository; + private uint dbus_watch_id; + + public ConversationListModel() { + _conversations = new TreeSet((a, b) => { + // Sort by date in descending order (newest first) + return (int)(b.date - a.date); + }); + + connect_to_dbus.begin(); + } + + ~ConversationListModel() { + if (dbus_watch_id > 0) { + Bus.unwatch_name(dbus_watch_id); + } + } + + private async void connect_to_dbus() { + bool connected = false; + const string path = "/net/buzzert/kordophonecd/daemon"; + + try { + debug("Trying to connect to DBus service at path: %s", path); + repository = yield Bus.get_proxy(BusType.SESSION, + "net.buzzert.kordophonecd", + path); + + // Test the connection + repository.get_version(); + + // If we get here, connection succeeded + debug("Connected to DBus service at path: %s", path); + connected = true; + + // Listen for updates + repository.conversations_updated.connect(load_conversations); + + // Initial load + load_conversations(); + } catch (Error e) { + debug("Failed to connect to kordophonecd at %s: %s", path, e.message); + } + + if (!connected) { + warning("Failed to connect to kordophonecd on any known path"); + + // Watch for the service to appear + dbus_watch_id = Bus.watch_name(BusType.SESSION, + "net.buzzert.kordophonecd", + BusNameWatcherFlags.AUTO_START, + () => { + connect_to_dbus.begin(); + }, + null); + } + } + + public void load_conversations() { + if (repository == null) { + return; + } + + try { + Variant conversations_variant = repository.get_conversations(); + + // Clear existing set + uint old_count = _conversations.size; + _conversations.clear(); + + // Notify of removal + if (old_count > 0) { + items_changed(0, old_count, 0); + } + + // Process each conversation + size_t n_children = conversations_variant.n_children(); + uint position = 0; + + for (size_t i = 0; i < n_children; i++) { + Variant child = conversations_variant.get_child_value(i); + var conversation = new Conversation.from_variant(child); + _conversations.add(conversation); + position++; + } + + // Notify of additions + if (position > 0) { + items_changed(0, 0, position); + } + } 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/src/conversation-list-view.vala b/src/conversation-list-view.vala index 99f9c3f..9342272 100644 --- a/src/conversation-list-view.vala +++ b/src/conversation-list-view.vala @@ -5,28 +5,43 @@ public class ConversationListView : Adw.Bin { private Adw.ToolbarView container; private ListBox list_box; + private ScrolledWindow scrolled_window; private Adw.HeaderBar header_bar; + private ConversationListModel conversation_model; 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); - container.set_content (list_box); + scrolled_window.set_child (list_box); header_bar = new Adw.HeaderBar (); header_bar.set_title_widget (new Label ("Kordophone")); container.add_top_bar (header_bar); - // Populate with test data - for (int i = 0; i < 10; i++) { - var row = new ActionRow (); - row.title = "Conversation %d".printf(i); - list_box.append (row); - } + // Set up refresh button + var refresh_button = new Button.from_icon_name ("view-refresh-symbolic"); + refresh_button.tooltip_text = "Refresh Conversations"; + refresh_button.clicked.connect (() => { + if (conversation_model != null) { + conversation_model.load_conversations (); + } + }); + header_bar.pack_end (refresh_button); + + // Set up model and bind to list + conversation_model = new ConversationListModel (); + list_box.bind_model (conversation_model, create_conversation_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/src/conversation-row.vala b/src/conversation-row.vala new file mode 100644 index 0000000..260e9ad --- /dev/null +++ b/src/conversation-row.vala @@ -0,0 +1,45 @@ +using Adw; +using Gtk; + +public class ConversationRow : Adw.ActionRow { + private Label? unread_badge; + + public ConversationRow(Conversation conversation) { + Object(); + + title = conversation.display_name; + subtitle = conversation.last_message_preview; + subtitle_lines = 1; + + // Add unread badge if needed + if (conversation.is_unread && conversation.unread_count > 0) { + unread_badge = new Label(conversation.unread_count.to_string()); + unread_badge.add_css_class("badge"); + unread_badge.add_css_class("accent"); + add_suffix(unread_badge); + } + + // 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); + } + } + } +} \ No newline at end of file diff --git a/src/conversation.vala b/src/conversation.vala new file mode 100644 index 0000000..b58f038 --- /dev/null +++ b/src/conversation.vala @@ -0,0 +1,77 @@ +using GLib; + +public class Conversation : Object { + public string id { get; set; default = ""; } + public string last_message_preview { get; set; default = ""; } + public bool is_unread { get; set; default = false; } + 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 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.join(", ", participants); + } + + return "Untitled"; + } + } + + private string? _display_name = null; + + public Conversation.from_variant (Variant dict) { + id = ""; + last_message_preview = ""; + participants = new string[0]; + + if (dict.get_type_string() != "a{sv}") { + warning("Expected dictionary variant, got %s", dict.get_type_string()); + return; + } + + // Safe extraction with type checking + Variant? id_variant = dict.lookup_value("id", VariantType.STRING); + if (id_variant != null) { + id = id_variant.get_string(); + } + + Variant? display_name_variant = dict.lookup_value("display_name", VariantType.STRING); + if (display_name_variant != null) { + _display_name = display_name_variant.get_string(); + } + + Variant? last_message_variant = dict.lookup_value("last_message_preview", VariantType.STRING); + if (last_message_variant != null) { + last_message_preview = last_message_variant.get_string(); + } + + Variant? is_unread_variant = dict.lookup_value("is_unread", VariantType.BOOLEAN); + if (is_unread_variant != null) { + is_unread = is_unread_variant.get_boolean(); + } + + Variant? date_variant = dict.lookup_value("date", VariantType.INT64); + if (date_variant != null) { + date = date_variant.get_int64(); + } + + Variant? participants_variant = dict.lookup_value("participants", new VariantType("as")); + if (participants_variant != null) { + participants = participants_variant.dup_strv(); + } + + Variant? unread_count_variant = dict.lookup_value("unread_count", VariantType.INT32); + if (unread_count_variant != null) { + unread_count = unread_count_variant.get_int32(); + } + } +} \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 1c0192b..9476a2b 100644 --- a/src/meson.build +++ b/src/meson.build @@ -1,16 +1,23 @@ dependencies = [ dependency('gtk4', required : true), - dependency('libadwaita-1', required : true) + dependency('libadwaita-1', required : true), + dependency('gio-2.0', required : true), + dependency('gee-0.8', required : true) ] sources = [ 'kordophone-application.vala', 'main-window.vala', 'conversation-list-view.vala', + 'conversation.vala', + 'conversation-row.vala', + 'conversation-list-model.vala', + 'repository-service.vala' ] executable('kordophone', sources, dependencies : dependencies, + vala_args: ['--pkg', 'posix'], install : true ) \ No newline at end of file diff --git a/src/repository-service.vala b/src/repository-service.vala new file mode 100644 index 0000000..44d90b1 --- /dev/null +++ b/src/repository-service.vala @@ -0,0 +1,18 @@ +using GLib; + +[DBus (name = "net.buzzert.kordophone.Repository")] +public interface RepositoryService : Object { + public abstract string get_version() throws DBusError, IOError; + + [DBus (signature = "aa{sv}")] + public abstract Variant get_conversations() throws DBusError, IOError; + + public abstract void sync_all_conversations() throws DBusError, IOError; + public abstract void sync_conversation(string conversation_id) throws DBusError, IOError; + + [DBus (signature = "aa{sv}")] + public abstract Variant get_messages(string conversation_id, string last_message_id) throws DBusError, IOError; + + public signal void conversations_updated(); + public signal void messages_updated(string conversation_id); +} \ No newline at end of file