From 7e7dcedaf31ee35499875491c9f569c575d28435 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Mon, 14 Feb 2022 14:55:59 +0100 Subject: Port from GTK3 to GTK4 --- .../conversation_list_item_factory.vala | 245 +++++++++++++++++++++ .../conversation_list/conversation_list_model.vala | 141 ++++++++++++ .../conversation_list/conversation_list_row.vala | 41 ++++ 3 files changed, 427 insertions(+) create mode 100644 main/src/ui/conversation_list/conversation_list_item_factory.vala create mode 100644 main/src/ui/conversation_list/conversation_list_model.vala create mode 100644 main/src/ui/conversation_list/conversation_list_row.vala (limited to 'main/src/ui/conversation_list') diff --git a/main/src/ui/conversation_list/conversation_list_item_factory.vala b/main/src/ui/conversation_list/conversation_list_item_factory.vala new file mode 100644 index 00000000..dc26d2f1 --- /dev/null +++ b/main/src/ui/conversation_list/conversation_list_item_factory.vala @@ -0,0 +1,245 @@ +using Gtk; +using Dino.Entities; +using Dino; +using Gee; +using Pango; +using Xmpp; + +namespace Dino.Ui.ConversationList { + + public static ListItemFactory get_item_factory() { + SignalListItemFactory item_factory = new SignalListItemFactory(); + item_factory.setup.connect((list_item) => { on_setup(list_item); }); + item_factory.bind.connect((list_item) => { on_bind(list_item); }); + return item_factory; + } + + public static void on_setup(ListItem listitem) { + listitem.child = new ConversationListRow(); + } + + public static void on_bind(ListItem listitem) { + ConversationViewModel list_model = (ConversationViewModel) listitem.get_item(); + ConversationListRow view = (ConversationListRow) listitem.get_child(); + StreamInteractor stream_interactor = list_model.stream_interactor; + + list_model.bind_property("name", view.name_label, "label"); + list_model.notify["latest-content-item"].connect((obj, _) => { + update_content_item(view, list_model.conversation, stream_interactor, ((ConversationViewModel) obj).latest_content_item); + }); + list_model.notify["unread-count"].connect((obj, _) => { + update_read(view, list_model.conversation, stream_interactor, (int) obj); + }); + + view.x_button.clicked.connect(() => list_model.closed() ); + + ConversationViewModel view_model = (ConversationViewModel) listitem.get_item(); + view.name_label.label = view_model.name; + if (view_model.latest_content_item != null) { + update_content_item(view, view_model.conversation, stream_interactor, view_model.latest_content_item); + } + update_read(view, view_model.conversation, stream_interactor, view_model.unread_count); + } + + private static void update_content_item(ConversationListRow view, Conversation conversation, StreamInteractor stream_interactor, ContentItem last_content_item) { + view.time_label.label = get_relative_time(last_content_item.time.to_local()); + view.image.set_conversation(stream_interactor, conversation); + + Label nick_label = view.nick_label; + Label message_label = view.message_label; + + switch (last_content_item.type_) { + case MessageItem.TYPE: + MessageItem message_item = last_content_item as MessageItem; + Message last_message = message_item.message; + + string body = last_message.body; + bool me_command = body.has_prefix("/me "); + + /* If we have a /me command, we always show the display + * name, and we don't set me_is_me on + * get_participant_display_name, since that will return + * "Me" (internationalized), whereas /me commands expect to + * be in the third person. We also omit the colon in this + * case, and strip off the /me prefix itself. */ + + if (conversation.type_ == Conversation.Type.GROUPCHAT || me_command) { + nick_label.label = Util.get_participant_display_name(stream_interactor, conversation, last_message.from, !me_command); + } else if (last_message.direction == Message.DIRECTION_SENT) { + nick_label.label = _("Me"); + } else { + nick_label.label = ""; + } + + if (me_command) { + /* Don't slice off the space after /me */ + body = body.slice("/me".length, body.length); + } else if (nick_label.label.length > 0) { + /* TODO: Is this valid for RTL languages? */ + nick_label.label += ": "; + } + + message_label.attributes.filter((attr) => attr.equal(attr_style_new(Pango.Style.ITALIC))); + message_label.label = Util.summarize_whitespaces_to_space(body); + + break; + case FileItem.TYPE: + FileItem file_item = last_content_item as FileItem; + FileTransfer transfer = file_item.file_transfer; + + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + // TODO properly display nick for oneself + nick_label.label = Util.get_participant_display_name(stream_interactor, conversation, file_item.file_transfer.from, true) + ": "; + } else { + nick_label.label = transfer.direction == Message.DIRECTION_SENT ? _("Me") + ": " : ""; + } + + bool file_is_image = transfer.mime_type != null && transfer.mime_type.has_prefix("image"); + message_label.attributes.insert(attr_style_new(Pango.Style.ITALIC)); + if (transfer.direction == Message.DIRECTION_SENT) { + message_label.label = (file_is_image ? _("Image sent") : _("File sent") ); + } else { + message_label.label = (file_is_image ? _("Image received") : _("File received") ); + } + break; + case CallItem.TYPE: + CallItem call_item = (CallItem) last_content_item; + Call call = call_item.call; + + nick_label.label = call.direction == Call.DIRECTION_OUTGOING ? _("Me") + ": " : ""; + message_label.attributes.insert(attr_style_new(Pango.Style.ITALIC)); + message_label.label = call.direction == Call.DIRECTION_OUTGOING ? _("Outgoing call") : _("Incoming call"); + break; + } + nick_label.visible = true; + message_label.visible = true; + } + + private void update_read(ConversationListRow view, Conversation conversation, StreamInteractor stream_interactor, int num_unread) { + Label unread_count_label = view.unread_count_label; + Label name_label = view.name_label; + Label time_label = view.time_label; + Label nick_label = view.nick_label; + Label message_label = view.message_label; + if (num_unread == 0) { + unread_count_label.visible = false; + + name_label.attributes.filter((attr) => attr.equal(attr_weight_new(Weight.BOLD))); + time_label.attributes.filter((attr) => attr.equal(attr_weight_new(Weight.BOLD))); + nick_label.attributes.filter((attr) => attr.equal(attr_weight_new(Weight.BOLD))); + message_label.attributes.filter((attr) => attr.equal(attr_weight_new(Weight.BOLD))); + } else { + unread_count_label.label = num_unread.to_string(); + unread_count_label.visible = true; + + if (conversation.get_notification_setting(stream_interactor) == Conversation.NotifySetting.ON) { + unread_count_label.get_style_context().add_class("unread-count-notify"); + unread_count_label.get_style_context().remove_class("unread-count"); + } else { + unread_count_label.get_style_context().add_class("unread-count"); + unread_count_label.get_style_context().remove_class("unread-count-notify"); + } + + name_label.attributes.insert(attr_weight_new(Weight.BOLD)); + time_label.attributes.insert(attr_weight_new(Weight.BOLD)); + nick_label.attributes.insert(attr_weight_new(Weight.BOLD)); + message_label.attributes.insert(attr_weight_new(Weight.BOLD)); + } + + name_label.label = name_label.label; // TODO initializes redrawing, which would otherwise not happen. nicer? + time_label.label = time_label.label; + nick_label.label = nick_label.label; + message_label.label = message_label.label; + } + + private Widget generate_tooltip(StreamInteractor stream_interactor, Conversation conversation) { + Grid grid = new Grid() { row_spacing=5, column_homogeneous=false, column_spacing=5, margin_start=7, margin_end=7, margin_top=7, margin_bottom=7 }; + + Label label = new Label(conversation.counterpart.to_string()) { valign=Align.START, xalign=0, visible=true }; + label.attributes = new AttrList(); + label.attributes.insert(attr_weight_new(Weight.BOLD)); + + grid.attach(label, 0, 0, 2, 1); + + Gee.List? full_jids = stream_interactor.get_module(PresenceManager.IDENTITY).get_full_jids(conversation.counterpart, conversation.account); + if (full_jids == null) return grid; + + for (int i = 0; i < full_jids.size; i++) { + Jid full_jid = full_jids[i]; + string? show = stream_interactor.get_module(PresenceManager.IDENTITY).get_last_show(full_jid, conversation.account); + if (show == null) continue; + + int i_cache = i; + stream_interactor.get_module(EntityInfo.IDENTITY).get_identity.begin(conversation.account, full_jid, (_, res) => { + Xep.ServiceDiscovery.Identity? identity = stream_interactor.get_module(EntityInfo.IDENTITY).get_identity.end(res); + + Image image = new Image() { hexpand=false, valign=Align.CENTER, visible=true }; + if (identity != null && (identity.type_ == Xep.ServiceDiscovery.Identity.TYPE_PHONE || identity.type_ == Xep.ServiceDiscovery.Identity.TYPE_TABLET)) { + image.set_from_icon_name("dino-device-phone-symbolic"); + } else { + image.set_from_icon_name("dino-device-desktop-symbolic"); + } + + if (show == Presence.Stanza.SHOW_AWAY) { + Util.force_color(image, "#FF9800"); + } else if (show == Presence.Stanza.SHOW_XA || show == Presence.Stanza.SHOW_DND) { + Util.force_color(image, "#FF5722"); + } else { + Util.force_color(image, "#4CAF50"); + } + + string? status = null; + if (show == Presence.Stanza.SHOW_AWAY) { + status = "away"; + } else if (show == Presence.Stanza.SHOW_XA) { + status = "not available"; + } else if (show == Presence.Stanza.SHOW_DND) { + status = "do not disturb"; + } + + var sb = new StringBuilder(); + if (identity != null && identity.name != null) { + sb.append(identity.name); + } else if (full_jid.resourcepart != null) { + sb.append(full_jid.resourcepart); + } else { + return; + } + if (status != null) { + sb.append(" (").append(status).append(")"); + } + + Label resource = new Label(sb.str) { use_markup=true, hexpand=true, xalign=0, visible=true }; + + grid.attach(image, 0, i_cache + 1, 1, 1); + grid.attach(resource, 1, i_cache + 1, 1, 1); + }); + } + return grid; + } + + private static string get_relative_time(DateTime datetime) { + DateTime now = new DateTime.now_local(); + TimeSpan timespan = now.difference(datetime); + if (timespan > 365 * TimeSpan.DAY) { + return datetime.get_year().to_string(); + } else if (timespan > 7 * TimeSpan.DAY) { + // Day and month + // xgettext:no-c-format + return datetime.format(_("%b %d")); + } else if (timespan > 2 * TimeSpan.DAY) { + return datetime.format("%a"); + } else if (datetime.get_day_of_month() != now.get_day_of_month()) { + return _("Yesterday"); + } else if (timespan > 9 * TimeSpan.MINUTE) { + return datetime.format(Util.is_24h_format() ? + /* xgettext:no-c-format */ /* Time in 24h format (w/o seconds) */ _("%H∶%M") : + /* xgettext:no-c-format */ /* Time in 12h format (w/o seconds) */ _("%l∶%M %p")); + } else if (timespan > 1 * TimeSpan.MINUTE) { + ulong mins = (ulong) (timespan.abs() / TimeSpan.MINUTE); + return n("%i min ago", "%i mins ago", mins).printf(mins); + } else { + return _("Just now"); + } + } +} \ No newline at end of file diff --git a/main/src/ui/conversation_list/conversation_list_model.vala b/main/src/ui/conversation_list/conversation_list_model.vala new file mode 100644 index 00000000..9412e64a --- /dev/null +++ b/main/src/ui/conversation_list/conversation_list_model.vala @@ -0,0 +1,141 @@ +using Gtk; +using Dino.Entities; +using Dino; +using Gee; +using Xmpp; + +public class Dino.Ui.ConversationViewModel : Object { + public signal void closed(); + + public StreamInteractor stream_interactor { get; set; } + public Conversation conversation { get; set; } + public string name { get; set; } + public ContentItem? latest_content_item { get; set; } + public int unread_count { get; set; } +} + +public class Dino.Ui.ConversationListModel : Object, ListModel { + + public signal void closed_conversation(Conversation conversation); + + private HashMap conversation_view_model_hm = new HashMap(Conversation.hash_func, Conversation.equals_func); + private ArrayList view_models = new ArrayList(); + private StreamInteractor stream_interactor; + + public ConversationListModel(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + + stream_interactor.get_module(ConversationManager.IDENTITY).conversation_activated.connect(add_conversation); + stream_interactor.get_module(ConversationManager.IDENTITY).conversation_deactivated.connect(remove_conversation); + stream_interactor.get_module(ContentItemStore.IDENTITY).new_item.connect(on_content_item_received); + + foreach (Conversation conversation in stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations()) { + var view_model = create_view_model(conversation); + view_models.add(view_model); + conversation_view_model_hm[conversation] = view_model; + } + view_models.sort(sort); + items_changed(0, 0, get_n_items()); + + stream_interactor.get_module(RosterManager.IDENTITY).updated_roster_item.connect((account, jid, roster_item) => { + ConversationViewModel? view_model = get_view_model(account, jid, Conversation.Type.CHAT); + if (view_model == null) return; + view_model.name = Util.get_conversation_display_name(stream_interactor, view_model.conversation); + }); + stream_interactor.get_module(MucManager.IDENTITY).room_info_updated.connect((account, jid) => { + ConversationViewModel? view_model = get_view_model(account, jid, Conversation.Type.GROUPCHAT); + if (view_model == null) return; + view_model.name = Util.get_conversation_display_name(stream_interactor, view_model.conversation); + // bubble color might have changed + view_model.unread_count = stream_interactor.get_module(ChatInteraction.IDENTITY).get_num_unread(view_model.conversation); + }); + stream_interactor.get_module(MucManager.IDENTITY).private_room_occupant_updated.connect((account, room, occupant) => { + ConversationViewModel? view_model = get_view_model(account, room.bare_jid, Conversation.Type.GROUPCHAT); + if (view_model == null) return; + view_model.name = Util.get_conversation_display_name(stream_interactor, view_model.conversation); + }); + } + + public GLib.Object? get_item (uint position) { + if (position >= view_models.size) return null; + return view_models[(int)position]; + } + + public GLib.Type get_item_type () { + return GLib.Type.OBJECT; + } + + public uint get_n_items () { + return view_models.size; + } + + private ConversationViewModel create_view_model(Conversation conversation) { + var view_model = new ConversationViewModel(); + view_model.stream_interactor = stream_interactor; + view_model.conversation = conversation; + view_model.name = Util.get_conversation_display_name(stream_interactor, conversation); + view_model.latest_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); + view_model.unread_count = stream_interactor.get_module(ChatInteraction.IDENTITY).get_num_unread(conversation); + view_model.closed.connect(() => closed_conversation(conversation)); + + return view_model; + } + + private void add_conversation(Conversation conversation) { + var view_model = create_view_model(conversation); + + view_models.add(view_model); + conversation_view_model_hm[conversation] = view_model; + view_models.sort(sort); + + int idx = view_models.index_of(view_model); + items_changed(idx, 0, 1); + } + + private async void remove_conversation(Conversation conversation) { + ConversationViewModel? view_model = conversation_view_model_hm[conversation]; + if (view_model == null) return; + + int idx = view_models.index_of(view_model); + view_models.remove(view_model); + conversation_view_model_hm.unset(conversation); + items_changed(idx, 1, 0); + } + + private void on_content_item_received(ContentItem item, Conversation conversation) { + ConversationViewModel? view_model = conversation_view_model_hm[conversation]; + if (view_model == null) return; + + view_model.latest_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); + view_model.unread_count = stream_interactor.get_module(ChatInteraction.IDENTITY).get_num_unread(conversation); + + view_models.sort(sort); + items_changed(0, view_models.size, view_models.size); // TODO better + } + + private ConversationViewModel? get_view_model(Account account, Jid jid, Conversation.Type? conversation_ty) { + foreach (ConversationViewModel view_model in view_models) { + Conversation conversation = view_model.conversation; + if (conversation.account.equals(account) && conversation.counterpart.equals(jid)) { + if (conversation_ty != null && conversation.type_ != conversation_ty) continue; + return view_model; + } + } + return null; + } + + private int sort(ConversationViewModel vm1, ConversationViewModel vm2) { + Conversation c1 = vm1.conversation; + Conversation c2 = vm2.conversation; + + if (c1 == null || c2 == null) return 0; + if (c1.last_active == null) return -1; + if (c2.last_active == null) return 1; + + int comp = c2.last_active.compare(c1.last_active); + if (comp != 0) return comp; + + return Util.get_conversation_display_name(stream_interactor, c1) + .collate(Util.get_conversation_display_name(stream_interactor, c2)); + } +} \ No newline at end of file diff --git a/main/src/ui/conversation_list/conversation_list_row.vala b/main/src/ui/conversation_list/conversation_list_row.vala new file mode 100644 index 00000000..ab4e8cee --- /dev/null +++ b/main/src/ui/conversation_list/conversation_list_row.vala @@ -0,0 +1,41 @@ +using Gee; +using Gdk; +using Gtk; +using Pango; + +using Dino; +using Dino.Entities; +using Xmpp; + +[GtkTemplate (ui = "/im/dino/Dino/conversation_row.ui")] +public class Dino.Ui.ConversationListRow : ListBoxRow { + + [GtkChild] public unowned AvatarImage image; + [GtkChild] public unowned Label name_label; + [GtkChild] public unowned Label time_label; + [GtkChild] public unowned Label nick_label; + [GtkChild] public unowned Label message_label; + [GtkChild] public unowned Label unread_count_label; + [GtkChild] public unowned Button x_button; + [GtkChild] public unowned Revealer time_revealer; + [GtkChild] public unowned Revealer xbutton_revealer; + [GtkChild] public unowned Revealer unread_count_revealer; + [GtkChild] public unowned Revealer main_revealer; + + construct { + name_label.attributes = new AttrList(); + } + + public override void state_flags_changed(StateFlags flags) { + StateFlags curr_flags = get_state_flags(); + if ((curr_flags & StateFlags.PRELIGHT) != 0) { + time_revealer.set_reveal_child(false); + unread_count_revealer.set_reveal_child(false); + xbutton_revealer.set_reveal_child(true); + } else { + time_revealer.set_reveal_child(true); + unread_count_revealer.set_reveal_child(true); + xbutton_revealer.set_reveal_child(false); + } + } +} \ No newline at end of file -- cgit v1.2.3-54-g00ecf