From 7ad52d9335579d03613036a7da9967fcf0c5d6b3 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Fri, 16 Sep 2022 11:23:18 +0200 Subject: [PATCH 01/13] OMEMO QR code: Switch to paintable, fix css --- main/data/theme.css | 2 +- plugins/omemo/src/ui/contact_details_dialog.vala | 6 +++--- plugins/omemo/vapi/libqrencode.vapi | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/main/data/theme.css b/main/data/theme.css index 94cf1da5..b689d96c 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -386,6 +386,6 @@ box.dino-input-error .chat-input-status.input-status-highlight-once { box-shadow: 0 0 2px 0 rgba(0,0,0,0.5); } -.qrcode-container { +.qrcode-container > contents { background: white; /* Color of the quiet zone. MUST have the same "reflectance" as light modules of the QR code. */ } diff --git a/plugins/omemo/src/ui/contact_details_dialog.vala b/plugins/omemo/src/ui/contact_details_dialog.vala index ab216c58..2de28572 100644 --- a/plugins/omemo/src/ui/contact_details_dialog.vala +++ b/plugins/omemo/src/ui/contact_details_dialog.vala @@ -104,9 +104,9 @@ public class ContactDetailsDialog : Gtk.Dialog { const int QUIET_ZONE_MODULES = 4; // MUST be at least 4 const int MODULE_SIZE_PX = 4; // arbitrary - var qr_pixbuf = new QRcode(iri, 2) - .to_pixbuf(MODULE_SIZE_PX * qrcode_image.scale_factor); - qrcode_image.set_from_pixbuf(qr_pixbuf); + var qr_paintable = new QRcode(iri, 2) + .to_paintable(MODULE_SIZE_PX * qrcode_image.scale_factor); + qrcode_image.paintable = qr_paintable; qrcode_image.margin_top = qrcode_image.margin_end = qrcode_image.margin_bottom = qrcode_image.margin_start = QUIET_ZONE_MODULES*MODULE_SIZE_PX; qrcode_popover.add_css_class("qrcode-container"); diff --git a/plugins/omemo/vapi/libqrencode.vapi b/plugins/omemo/vapi/libqrencode.vapi index 79f98b62..360f74a5 100644 --- a/plugins/omemo/vapi/libqrencode.vapi +++ b/plugins/omemo/vapi/libqrencode.vapi @@ -1,5 +1,3 @@ -using Gdk; - [CCode (cheader_filename = "qrencode.h")] namespace Qrencode { @@ -36,13 +34,14 @@ namespace Qrencode { [CCode (cname = "QRcode_encodeString")] public QRcode (string str, int version = 0, ECLevel level = ECLevel.L, EncodeMode hint = EncodeMode.EIGHT_BIT, bool casesensitive = true); - public Pixbuf to_pixbuf(int module_size) { + public Gdk.Paintable to_paintable(int module_size) { GLib.assert(module_size > 0); var dst_width = width*module_size; var dst_data = new uint8[dst_width*dst_width*3]; expand_and_upsample(data,width,width, dst_data,dst_width,dst_width); - return new Pixbuf.from_data(dst_data, - Colorspace.RGB, false, 8, dst_width, dst_width, dst_width*3); + return new Gdk.MemoryTexture(dst_width, dst_width, Gdk.MemoryFormat.R8G8B8, + new GLib.Bytes.take((owned) dst_data), dst_width*3); + } /** Does 2D nearest-neighbor upsampling of an array of single-byte From 146af3152475f12c9b19a92c4779a53f6fc517ce Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 18 Sep 2022 19:44:26 +0200 Subject: [PATCH 02/13] Move icons out of scalable/ui/ since it's not allowed by icon naming spec --- main/CMakeLists.txt | 11 +++++------ .../dino-conversation-list-placeholder-arrow.svg | 0 .../{emotes => actions}/dino-emoticon-symbolic.svg | 0 .../{ui => actions}/dino-qr-code-symbolic.svg | 0 .../{ui => status}/dino-party-popper-symbolic.svg | 0 main/src/ui/main_window.vala | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) rename main/data/{icons/scalable/ui => }/dino-conversation-list-placeholder-arrow.svg (100%) rename main/data/icons/scalable/{emotes => actions}/dino-emoticon-symbolic.svg (100%) rename main/data/icons/scalable/{ui => actions}/dino-qr-code-symbolic.svg (100%) rename main/data/icons/scalable/{ui => status}/dino-party-popper-symbolic.svg (100%) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6b97444d..f5796651 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -14,7 +14,11 @@ find_packages(MAIN_PACKAGES REQUIRED ) set(RESOURCE_LIST + dino-conversation-list-placeholder-arrow.svg + icons/scalable/actions/dino-account-plus-symbolic.svg + icons/scalable/actions/dino-emoticon-symbolic.svg + icons/scalable/actions/dino-qr-code-symbolic.svg icons/scalable/apps/im.dino.Dino.svg icons/scalable/apps/im.dino.Dino-symbolic.svg @@ -27,8 +31,6 @@ set(RESOURCE_LIST icons/scalable/devices/dino-phone-ring-symbolic.svg icons/scalable/devices/dino-phone-symbolic.svg - icons/scalable/emotes/dino-emoticon-symbolic.svg - icons/scalable/mimetypes/dino-file-document-symbolic.svg icons/scalable/mimetypes/dino-file-download-symbolic.svg icons/scalable/mimetypes/dino-file-image-symbolic.svg @@ -40,6 +42,7 @@ set(RESOURCE_LIST icons/scalable/status/dino-double-tick-symbolic.svg icons/scalable/status/dino-microphone-off-symbolic.svg icons/scalable/status/dino-microphone-symbolic.svg + icons/scalable/status/dino-party-popper-symbolic.svg icons/scalable/status/dino-security-high-symbolic.svg icons/scalable/status/dino-status-away.svg icons/scalable/status/dino-status-chat.svg @@ -49,10 +52,6 @@ set(RESOURCE_LIST icons/scalable/status/dino-video-off-symbolic.svg icons/scalable/status/dino-video-symbolic.svg - icons/scalable/ui/dino-conversation-list-placeholder-arrow.svg - icons/scalable/ui/dino-qr-code-symbolic.svg - icons/scalable/ui/dino-party-popper-symbolic.svg - add_conversation/add_contact_dialog.ui add_conversation/add_groupchat_dialog.ui add_conversation/conference_details_fragment.ui diff --git a/main/data/icons/scalable/ui/dino-conversation-list-placeholder-arrow.svg b/main/data/dino-conversation-list-placeholder-arrow.svg similarity index 100% rename from main/data/icons/scalable/ui/dino-conversation-list-placeholder-arrow.svg rename to main/data/dino-conversation-list-placeholder-arrow.svg diff --git a/main/data/icons/scalable/emotes/dino-emoticon-symbolic.svg b/main/data/icons/scalable/actions/dino-emoticon-symbolic.svg similarity index 100% rename from main/data/icons/scalable/emotes/dino-emoticon-symbolic.svg rename to main/data/icons/scalable/actions/dino-emoticon-symbolic.svg diff --git a/main/data/icons/scalable/ui/dino-qr-code-symbolic.svg b/main/data/icons/scalable/actions/dino-qr-code-symbolic.svg similarity index 100% rename from main/data/icons/scalable/ui/dino-qr-code-symbolic.svg rename to main/data/icons/scalable/actions/dino-qr-code-symbolic.svg diff --git a/main/data/icons/scalable/ui/dino-party-popper-symbolic.svg b/main/data/icons/scalable/status/dino-party-popper-symbolic.svg similarity index 100% rename from main/data/icons/scalable/ui/dino-party-popper-symbolic.svg rename to main/data/icons/scalable/status/dino-party-popper-symbolic.svg diff --git a/main/src/ui/main_window.vala b/main/src/ui/main_window.vala index 1f1a7688..14058a29 100644 --- a/main/src/ui/main_window.vala +++ b/main/src/ui/main_window.vala @@ -77,7 +77,7 @@ public class MainWindow : Gtk.Window { search_frame.set_child(global_search.get_widget()); Image conversation_list_placeholder_image = (Image) builder.get_object("conversation_list_placeholder_image"); - conversation_list_placeholder_image.set_from_pixbuf(new Pixbuf.from_resource("/im/dino/Dino/icons/scalable/ui/dino-conversation-list-placeholder-arrow.svg")); + conversation_list_placeholder_image.set_from_pixbuf(new Pixbuf.from_resource("/im/dino/Dino/dino-conversation-list-placeholder-arrow.svg")); } private void setup_headerbar() { From 21ab48e09aa6b0ade8f25bdc93f89f8d3aa462e7 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Mon, 3 Oct 2022 19:14:45 +0200 Subject: [PATCH 03/13] Fix channel join button not getting sensitive --- .../conference_details_fragment.vala | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/main/src/ui/add_conversation/conference_details_fragment.vala b/main/src/ui/add_conversation/conference_details_fragment.vala index 721c660e..618d19ab 100644 --- a/main/src/ui/add_conversation/conference_details_fragment.vala +++ b/main/src/ui/add_conversation/conference_details_fragment.vala @@ -12,17 +12,7 @@ protected class ConferenceDetailsFragment : Box { public signal void joined(); - public bool done { - get { - try { - Jid parsed_jid = new Jid(jid); - return parsed_jid.localpart != null && parsed_jid.resourcepart == null && nick != null; - } catch (InvalidJidError e) { - return false; - } - } - private set {} - } + public bool done { get; private set; } public Account account { owned get { return account_combobox.selected; } @@ -118,8 +108,14 @@ protected class ConferenceDetailsFragment : Box { // nick_entry.key_release_event.connect(on_nick_key_release_event); // password_entry.key_release_event.connect(on_password_key_release_event); -// jid_entry.key_release_event.connect(() => { done = true; return false; }); // just for notifying -// nick_entry.key_release_event.connect(() => { done = true; return false; }); + var jid_entry_controller = new EventControllerKey(); + jid_entry_controller.key_released.connect(() => { check_if_done(); }); + jid_entry.add_controller(jid_entry_controller); + + var nick_entry_controller = new EventControllerKey(); + nick_entry_controller.key_released.connect(() => { check_if_done(); }); + nick_entry.add_controller(nick_entry_controller); + notification_button.clicked.connect(() => { notification_revealer.set_reveal_child(false); }); @@ -195,6 +191,15 @@ protected class ConferenceDetailsFragment : Box { notification_revealer.set_reveal_child(true); } + private void check_if_done() { + try { + Jid parsed_jid = new Jid(jid); + done = parsed_jid.localpart != null && parsed_jid.resourcepart == null && nick != null; + } catch (InvalidJidError e) { + done = false; + } + } + // private bool on_jid_key_release_event(EventKey event) { // jid_label.label = jid_entry.text; // if (event.keyval == Key.Return) jid_stack.set_visible_child_name("label"); From 85342ee2eb2aa3e6d7599c503d17c00d861bafcb Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sat, 8 Oct 2022 13:19:03 +0200 Subject: [PATCH 04/13] Fix drag and drop uploading --- main/src/ui/conversation_view.vala | 6 ----- main/src/ui/conversation_view_controller.vala | 26 +++++++++---------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/main/src/ui/conversation_view.vala b/main/src/ui/conversation_view.vala index 6e07b0e8..7c93c4ff 100644 --- a/main/src/ui/conversation_view.vala +++ b/main/src/ui/conversation_view.vala @@ -55,12 +55,6 @@ public class ConversationView : Widget { } } - public override void dispose() { - // To prevent a warning when closing Dino - // "Can't set a target list on a widget until you've called gtk_drag_dest_set() to make the widget into a drag destination" -// Gtk.drag_dest_unset(this); - } - private void on_upper_notify() { print("on_upper_notify\n"); if (at_current_content) { diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index a2ac2655..50c0dcdb 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -154,20 +154,20 @@ public class ConversationViewController : Object { conversation_topic = null; } - private void update_file_upload_status() { - stream_interactor.get_module(FileManager.IDENTITY).is_upload_available.begin(conversation, (_, res) => { - bool upload_available = stream_interactor.get_module(FileManager.IDENTITY).is_upload_available.end(res); - chat_input_controller.set_file_upload_active(upload_available); - if (conversation.account.bare_jid.to_string().has_prefix("f")) { - if (drop_event_controller.widget == null) { - view.add_controller(drop_event_controller); - } - } else { - if (drop_event_controller.widget != null) { - view.remove_controller(drop_event_controller); - } + private async void update_file_upload_status() { + if (conversation == null) return; + + bool upload_available = yield stream_interactor.get_module(FileManager.IDENTITY).is_upload_available(conversation); + chat_input_controller.set_file_upload_active(upload_available); + if (upload_available && overlay_dialog == null) { + if (drop_event_controller.widget == null) { + view.add_controller(drop_event_controller); } - }); + } else { + if (drop_event_controller.widget != null) { + view.remove_controller(drop_event_controller); + } + } } private void update_conversation_display_name() { From 03878eee495cac8dcc8baf0ff4f84e4c9e76114e Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sat, 8 Oct 2022 14:49:42 +0200 Subject: [PATCH 05/13] Add account dialog: Reenable Next-button activation on enter --- main/src/ui/manage_accounts/add_account_dialog.vala | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/main/src/ui/manage_accounts/add_account_dialog.vala b/main/src/ui/manage_accounts/add_account_dialog.vala index daff3abf..d7bbe66b 100644 --- a/main/src/ui/manage_accounts/add_account_dialog.vala +++ b/main/src/ui/manage_accounts/add_account_dialog.vala @@ -134,7 +134,7 @@ public class AddAccountDialog : Gtk.Dialog { create_account_box.visible = false; register_box.visible = false; success_box.visible = false; -// set_default(sign_in_jid_continue_button); + set_default_widget(sign_in_jid_continue_button); sign_in_jid_error_label.label = ""; jid_entry.sensitive = true; @@ -174,7 +174,7 @@ public class AddAccountDialog : Gtk.Dialog { create_account_box.visible = false; register_box.visible = false; success_box.visible = false; -// set_default(sign_in_password_continue_button); + set_default_widget(sign_in_password_continue_button); sign_in_password_error_label.label = ""; sign_in_password_title.label = _("Sign in to %s").printf(login_jid.to_string()); @@ -184,7 +184,7 @@ public class AddAccountDialog : Gtk.Dialog { private void show_select_server() { server_entry.text = ""; server_entry.grab_focus(); -// set_default(select_server_continue); + set_default_widget(select_server_continue); server_list_box.row_activated.disconnect(on_server_list_row_activated); server_list_box.unselect_all(); @@ -209,7 +209,7 @@ public class AddAccountDialog : Gtk.Dialog { create_account_box.visible = false; success_box.visible = false; -// set_default(register_form_continue); + set_default_widget(register_form_continue); animate_window_resize(register_box); } @@ -223,7 +223,7 @@ public class AddAccountDialog : Gtk.Dialog { register_box.visible = false; success_description.label = _("You can now use the account %s.").printf("" + Markup.escape_text(account.bare_jid.to_string()) + ""); -// set_default(success_continue_button); + set_default_widget(success_continue_button); } private void on_jid_entry_changed() { @@ -334,7 +334,7 @@ public class AddAccountDialog : Gtk.Dialog { form_box.remove(widget); widget = form_box.get_first_child(); } -// form_box.foreach((widget) => { form_box.remove(widget); }); + register_title.label = _("Register on %s").printf(server.to_string()); if (form.oob != null) { From 7d8b08deca0aa4eb24def6b9af4ec180c0bc9a27 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 9 Oct 2022 11:39:17 +0200 Subject: [PATCH 06/13] Small fixes --- main/data/add_conversation/list_row.ui | 1 + main/src/ui/conversation_view_controller.vala | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/main/data/add_conversation/list_row.ui b/main/data/add_conversation/list_row.ui index 06b6dc7f..c0d7e517 100644 --- a/main/data/add_conversation/list_row.ui +++ b/main/data/add_conversation/list_row.ui @@ -17,6 +17,7 @@ + center vertical diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index 50c0dcdb..5844ef0a 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -82,7 +82,7 @@ public class ConversationViewController : Object { } }); stream_interactor.get_module(RosterManager.IDENTITY).updated_roster_item.connect((account, jid, roster_item) => { - if (conversation.account.equals(account) && conversation.counterpart.equals(jid)) { + if (conversation != null && conversation.account.equals(account) && conversation.counterpart.equals(jid)) { update_conversation_display_name(); } }); From 9c736af765d8c62838440afbfd2ad7ee78b44951 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 9 Oct 2022 11:38:59 +0200 Subject: [PATCH 07/13] Fix regression with channel join button not getting sensitive fixes #1284 --- main/src/ui/add_conversation/conference_details_fragment.vala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/main/src/ui/add_conversation/conference_details_fragment.vala b/main/src/ui/add_conversation/conference_details_fragment.vala index 618d19ab..83d5b507 100644 --- a/main/src/ui/add_conversation/conference_details_fragment.vala +++ b/main/src/ui/add_conversation/conference_details_fragment.vala @@ -31,6 +31,7 @@ protected class ConferenceDetailsFragment : Box { jid_label.label = value; jid_entry.text = value; jid_stack.set_visible_child_name("label"); + check_if_done(); } } public string? nick { @@ -39,6 +40,7 @@ protected class ConferenceDetailsFragment : Box { nick_label.label = value ?? ""; nick_entry.text = value ?? ""; nick_stack.set_visible_child_name("label"); + check_if_done(); } } public string? password { @@ -116,6 +118,7 @@ protected class ConferenceDetailsFragment : Box { nick_entry_controller.key_released.connect(() => { check_if_done(); }); nick_entry.add_controller(nick_entry_controller); + check_if_done(); notification_button.clicked.connect(() => { notification_revealer.set_reveal_child(false); }); From 6c6e7e3aa7935ec513b7e5ea9b53a92b741ecf92 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Fri, 8 Jul 2022 16:33:40 +0200 Subject: [PATCH 08/13] Rewrite MAM logic and add MUC MAM --- libdino/CMakeLists.txt | 1 + libdino/src/entity/conversation.vala | 9 +- libdino/src/service/chat_interaction.vala | 2 +- libdino/src/service/conversation_manager.vala | 2 +- libdino/src/service/database.vala | 33 +- libdino/src/service/history_sync.vala | 557 ++++++++++++++++++ libdino/src/service/message_processor.vala | 282 +-------- libdino/src/service/module_manager.vala | 2 +- libdino/src/service/muc_manager.vala | 23 +- .../ui/add_conversation/conference_list.vala | 3 +- xmpp-vala/CMakeLists.txt | 3 + xmpp-vala/src/module/xep/0045_muc/module.vala | 13 +- .../xep/0059_result_set_management.vala | 30 + .../src/module/xep/0203_delayed_delivery.vala | 2 +- xmpp-vala/src/module/xep/0272_muji.vala | 2 +- .../module/xep/0297_stanza_forwarding.vala | 3 + .../0313_2_message_archive_management.vala | 80 +++ .../xep/0313_message_archive_management.vala | 146 +++-- .../module/xep/0353_call_invite_message.vala | 2 +- .../xep/0353_jingle_message_initiation.vala | 2 +- 20 files changed, 838 insertions(+), 359 deletions(-) create mode 100644 libdino/src/service/history_sync.vala create mode 100644 xmpp-vala/src/module/xep/0059_result_set_management.vala create mode 100644 xmpp-vala/src/module/xep/0297_stanza_forwarding.vala create mode 100644 xmpp-vala/src/module/xep/0313_2_message_archive_management.vala diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 20f5ffee..6c120346 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -42,6 +42,7 @@ SOURCES src/service/entity_info.vala src/service/file_manager.vala src/service/file_transfer_storage.vala + src/service/history_sync.vala src/service/jingle_file_transfers.vala src/service/message_correction.vala src/service/message_processor.vala diff --git a/libdino/src/entity/conversation.vala b/libdino/src/entity/conversation.vala index 800a28a2..9376dca9 100644 --- a/libdino/src/entity/conversation.vala +++ b/libdino/src/entity/conversation.vala @@ -22,6 +22,7 @@ public class Conversation : Object { public Jid counterpart { get; private set; } public string? nickname { get; set; } public bool active { get; set; default = false; } + public DateTime active_last_changed { get; private set; } private DateTime? _last_active; public DateTime? last_active { get { return _last_active; } @@ -63,6 +64,7 @@ public class Conversation : Object { if (type_ == Conversation.Type.GROUPCHAT_PM) counterpart = counterpart.with_resource(resource); nickname = type_ == Conversation.Type.GROUPCHAT ? resource : null; active = row[db.conversation.active]; + active_last_changed = new DateTime.from_unix_utc(row[db.conversation.active_last_changed]); int64? last_active = row[db.conversation.last_active]; if (last_active != null) this.last_active = new DateTime.from_unix_utc(last_active); encryption = (Encryption) row[db.conversation.encryption]; @@ -78,12 +80,15 @@ public class Conversation : Object { public void persist(Database db) { this.db = db; + this.active_last_changed = new DateTime.now_utc(); + var insert = db.conversation.insert() .value(db.conversation.account_id, account.id) .value(db.conversation.jid_id, db.get_jid_id(counterpart)) .value(db.conversation.type_, type_) .value(db.conversation.encryption, encryption) .value(db.conversation.active, active) + .value(db.conversation.active_last_changed, (long) active_last_changed.to_unix()) .value(db.conversation.notification, notify_setting) .value(db.conversation.send_typing, send_typing) .value(db.conversation.send_marker, send_marker); @@ -176,7 +181,9 @@ public class Conversation : Object { case "nickname": update.set(db.conversation.resource, nickname); break; case "active": - update.set(db.conversation.active, active); break; + update.set(db.conversation.active, active); + update.set(db.conversation.active_last_changed, (long) new DateTime.now_utc().to_unix()); + break; case "last-active": if (last_active != null) { update.set(db.conversation.last_active, (long) last_active.to_unix()); diff --git a/libdino/src/service/chat_interaction.vala b/libdino/src/service/chat_interaction.vala index 00c611db..1254a574 100644 --- a/libdino/src/service/chat_interaction.vala +++ b/libdino/src/service/chat_interaction.vala @@ -188,7 +188,7 @@ public class ChatInteraction : StreamInteractionModule, Object { } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - if (Xep.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null) return false; + if (Xmpp.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null) return false; ChatInteraction outer = stream_interactor.get_module(ChatInteraction.IDENTITY); outer.send_delivery_receipt(message, stanza, conversation); diff --git a/libdino/src/service/conversation_manager.vala b/libdino/src/service/conversation_manager.vala index 99cc9039..59ccbac4 100644 --- a/libdino/src/service/conversation_manager.vala +++ b/libdino/src/service/conversation_manager.vala @@ -176,7 +176,7 @@ public class ConversationManager : StreamInteractionModule, Object { conversation.last_active = message.time; if (stanza != null) { - bool is_mam_message = Xep.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null; + bool is_mam_message = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null; bool is_recent = message.time.compare(new DateTime.now_utc().add_days(-3)) > 0; if (is_mam_message && !is_recent) return false; } diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 0300112a..25a6b477 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 22; + private const int VERSION = 23; public class AccountTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -193,6 +193,7 @@ public class Database : Qlite.Database { public Column jid_id = new Column.Integer("jid_id") { not_null = true }; public Column resource = new Column.Text("resource") { min_version=1 }; public Column active = new Column.BoolInt("active"); + public Column active_last_changed = new Column.Integer("active_last_changed") { not_null=true, default="0", min_version=23 }; public Column last_active = new Column.Long("last_active"); public Column type_ = new Column.Integer("type"); public Column encryption = new Column.Integer("encryption"); @@ -204,7 +205,7 @@ public class Database : Qlite.Database { internal ConversationTable(Database db) { base(db, "conversation"); - init({id, account_id, jid_id, resource, active, last_active, type_, encryption, read_up_to, read_up_to_item, notification, send_typing, send_marker}); + init({id, account_id, jid_id, resource, active, active_last_changed, last_active, type_, encryption, read_up_to, read_up_to_item, notification, send_typing, send_marker}); } } @@ -263,15 +264,16 @@ public class Database : Qlite.Database { public class MamCatchupTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column account_id = new Column.Integer("account_id") { not_null = true }; - public Column from_end = new Column.BoolInt("from_end"); - public Column from_id = new Column.Text("from_id"); + public Column server_jid = new Column.Text("server_jid") { not_null = true }; + public Column from_id = new Column.Text("from_id") { not_null = true }; public Column from_time = new Column.Long("from_time") { not_null = true }; - public Column to_id = new Column.Text("to_id"); + public Column from_end = new Column.BoolInt("from_end") { not_null = true }; + public Column to_id = new Column.Text("to_id") { not_null = true }; public Column to_time = new Column.Long("to_time") { not_null = true }; internal MamCatchupTable(Database db) { base(db, "mam_catchup"); - init({id, account_id, from_end, from_id, from_time, to_id, to_time}); + init({id, account_id, server_jid, from_end, from_id, from_time, to_id, to_time}); } } @@ -474,6 +476,25 @@ public class Database : Qlite.Database { // FROM call2"); // exec("DROP TABLE call2"); } + if (oldVersion < 23) { + try { + exec("ALTER TABLE mam_catchup RENAME TO mam_catchup2"); + mam_catchup.create_table_at_version(VERSION); + exec("""INSERT INTO mam_catchup (id, account_id, server_jid, from_id, from_time, from_end, to_id, to_time) + SELECT mam_catchup2.id, account_id, bare_jid, ifnull(from_id, ""), from_time, ifnull(from_end, 0), ifnull(to_id, ""), to_time + FROM mam_catchup2 JOIN account ON mam_catchup2.account_id=account.id"""); + exec("DROP TABLE mam_catchup2"); + } catch (Error e) { + error("Failed to upgrade to database version 23 (mam_catchup): %s", e.message); + } + + try { + long active_last_updated = (long) new DateTime.now_utc().to_unix(); + exec(@"UPDATE conversation SET active_last_changed=$active_last_updated WHERE active_last_changed=0"); + } catch (Error e) { + error("Failed to upgrade to database version 23 (conversation): %s", e.message); + } + } } public ArrayList get_accounts() { diff --git a/libdino/src/service/history_sync.vala b/libdino/src/service/history_sync.vala new file mode 100644 index 00000000..92a9e9e4 --- /dev/null +++ b/libdino/src/service/history_sync.vala @@ -0,0 +1,557 @@ +using Gee; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; +using Qlite; + +public class Dino.HistorySync { + + private StreamInteractor stream_interactor; + private Database db; + + public HashMap> current_catchup_id = new HashMap>(Account.hash_func, Account.equals_func); + public HashMap> mam_times = new HashMap>(); + public HashMap hitted_range = new HashMap(); + + // Server ID of the latest message of the previous segment + public HashMap catchup_until_id = new HashMap(Account.hash_func, Account.equals_func); + // Time of the latest message of the previous segment + public HashMap catchup_until_time = new HashMap(Account.hash_func, Account.equals_func); + + private HashMap> stanzas = new HashMap>(); + + public class HistorySync(Database db, StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + this.db = db; + + stream_interactor.account_added.connect(on_account_added); + + stream_interactor.connection_manager.stream_opened.connect((account, stream) => { + debug("MAM: [%s] Reset catchup_id", account.bare_jid.to_string()); + current_catchup_id.unset(account); + }); + } + + public bool process(Account account, Xmpp.MessageStanza message_stanza) { + var mam_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message_stanza); + + if (mam_flag != null) { + process_mam_message(account, message_stanza, mam_flag); + return true; + } else { + update_latest_db_range(account, message_stanza); + return false; + } + } + + public void update_latest_db_range(Account account, Xmpp.MessageStanza message_stanza) { + Jid mam_server = stream_interactor.get_module(MucManager.IDENTITY).might_be_groupchat(message_stanza.from, account) ? message_stanza.from.bare_jid : account.bare_jid; + + if (!current_catchup_id.has_key(account) || !current_catchup_id[account].has_key(mam_server)) return; + + string? stanza_id = UniqueStableStanzaIDs.get_stanza_id(message_stanza, mam_server); + if (stanza_id == null) return; + + db.mam_catchup.update() + .with(db.mam_catchup.id, "=", current_catchup_id[account][mam_server]) + .set(db.mam_catchup.to_time, (long)new DateTime.now_utc().to_unix()) + .set(db.mam_catchup.to_id, stanza_id) + .perform(); + } + + public void process_mam_message(Account account, Xmpp.MessageStanza message_stanza, Xmpp.MessageArchiveManagement.MessageFlag mam_flag) { + Jid mam_server = mam_flag.sender_jid; + Jid message_author = message_stanza.from; + + // MUC servers may only send MAM messages from that MUC + bool is_muc_mam = stream_interactor.get_module(MucManager.IDENTITY).might_be_groupchat(mam_server, account) && + message_author.equals_bare(mam_server); + + bool from_our_server = mam_server.equals_bare(account.bare_jid); + + if (!is_muc_mam && !from_our_server) { + warning("Received alleged MAM message from %s, ignoring", mam_server.to_string()); + return; + } + + if (!stanzas.has_key(mam_flag.query_id)) stanzas[mam_flag.query_id] = new ArrayList(); + stanzas[mam_flag.query_id].add(message_stanza); + + print(@"[$(message_stanza.from)] qid $(mam_flag.query_id) time $(mam_flag.server_time) $(mam_flag.mam_id) $(message_stanza.body ?? "[none]")\n"); + } + + private void on_unprocessed_message(Account account, XmppStream stream, MessageStanza message) { + // Check that it's a legit MAM server + bool is_muc_mam = stream_interactor.get_module(MucManager.IDENTITY).might_be_groupchat(message.from, account); + bool from_our_server = message.from.equals_bare(account.bare_jid); + if (!is_muc_mam && !from_our_server) return; + + // Get the server time of the message and store it in `mam_times` + Xmpp.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xmpp.MessageArchiveManagement.Flag.IDENTITY) : null; + if (mam_flag == null) return; + string? id = message.stanza.get_deep_attribute(mam_flag.ns_ver + ":result", "id"); + if (id == null) return; + StanzaNode? delay_node = message.stanza.get_deep_subnode(mam_flag.ns_ver + ":result", StanzaForwarding.NS_URI + ":forwarded", DelayedDelivery.NS_URI + ":delay"); + if (delay_node == null) { + warning("MAM result did not contain delayed time %s", message.stanza.to_string()); + return; + } + DateTime? time = DelayedDelivery.get_time_for_node(delay_node); + if (time == null) return; + mam_times[account][id] = time; + + // Check if this is the target message + string? query_id = message.stanza.get_deep_attribute(mam_flag.ns_ver + ":result", mam_flag.ns_ver + ":queryid"); + if (query_id != null && id == catchup_until_id[account]) { + debug("MAM: [%s] Hitted range (id) %s", account.bare_jid.to_string(), id); + hitted_range[query_id] = -2; + } + } + + public void on_server_id_duplicate(Account account, Xmpp.MessageStanza message_stanza, Entities.Message message) { + Xmpp.MessageArchiveManagement.MessageFlag? mam_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message_stanza); + if (mam_flag == null) return; + +// debug(@"MAM: [%s] Hitted range duplicate server id. id %s qid %s", account.bare_jid.to_string(), message.server_id, mam_flag.query_id); + if (catchup_until_time.has_key(account) && mam_flag.server_time.compare(catchup_until_time[account]) < 0) { + hitted_range[mam_flag.query_id] = -1; +// debug(@"MAM: [%s] In range (time) %s < %s", account.bare_jid.to_string(), mam_flag.server_time.to_string(), catchup_until_time[account].to_string()); + } + } + + public async void fetch_everything(Account account, Jid mam_server, DateTime until_earliest_time = new DateTime.from_unix_utc(0)) { + print(@"Fetch everything for $(mam_server) %s\n".printf(until_earliest_time != null ? @"(until $until_earliest_time)" : "")); + RowOption latest_row_opt = db.mam_catchup.select() + .with(db.mam_catchup.account_id, "=", account.id) + .with(db.mam_catchup.server_jid, "=", mam_server.to_string()) + .with(db.mam_catchup.to_time, ">=", (long) until_earliest_time.to_unix()) + .order_by(db.mam_catchup.to_time, "DESC") + .single().row(); + Row? latest_row = latest_row_opt.is_present() ? latest_row_opt.inner : null; + + Row? new_row = yield fetch_latest_page(account, mam_server, latest_row, until_earliest_time); + + if (new_row != null) { + current_catchup_id[account][mam_server] = new_row[db.mam_catchup.id]; + } else if (latest_row != null) { + current_catchup_id[account][mam_server] = latest_row[db.mam_catchup.id]; + } + + // Set the previous and current row + print(@"$(new_row == null) $(latest_row == null)\n"); + Row? previous_row = null; + Row? current_row = null; + if (new_row != null) { + print(@"Fetch everything $(mam_server) a\n"); + current_row = new_row; + previous_row = latest_row; + } else if (latest_row != null) { + print(@"Fetch everything $(mam_server) b\n"); + current_row = latest_row; + RowOption previous_row_opt = db.mam_catchup.select() + .with(db.mam_catchup.account_id, "=", account.id) + .with(db.mam_catchup.server_jid, "=", mam_server.to_string()) + .with(db.mam_catchup.to_time, "<", current_row[db.mam_catchup.from_time]) + .with(db.mam_catchup.to_time, ">=", (long) until_earliest_time.to_unix()) + .order_by(db.mam_catchup.to_time, "DESC") + .single().row(); + previous_row = previous_row_opt.is_present() ? previous_row_opt.inner : null; + } + + print(@"Fetch everything $(mam_server) c $(current_row == null) $(previous_row == null)\n"); + // Fetch messages between two db ranges and merge them + while (current_row != null && previous_row != null) { + if (current_row[db.mam_catchup.from_end]) return; + + print("FETCH BETWEEN RANGES\n"); + current_row = yield fetch_between_ranges(account, mam_server, previous_row, current_row); + if (current_row == null) return; + + RowOption previous_row_opt = db.mam_catchup.select() + .with(db.mam_catchup.account_id, "=", account.id) + .with(db.mam_catchup.server_jid, "=", mam_server.to_string()) + .with(db.mam_catchup.to_time, "<", current_row[db.mam_catchup.from_time]) + .with(db.mam_catchup.to_time, ">=", (long) until_earliest_time.to_unix()) + .order_by(db.mam_catchup.to_time, "DESC") + .single().row(); + previous_row = previous_row_opt.is_present() ? previous_row_opt.inner : null; + } + + // We're at the earliest range. Try to expand it even further back. + if (current_row == null || current_row[db.mam_catchup.from_end]) return; + // We don't want to fetch before the earliest range over and over again in MUCs if it's after until_earliest_time. + // For now, don't query if we are within a week of until_earliest_time + if (until_earliest_time != null && + current_row[db.mam_catchup.from_time] > until_earliest_time.add(-TimeSpan.DAY * 7).to_unix()) return; + print("FETCH BEFORE RANGE\n"); + yield fetch_before_range(account, mam_server, current_row, until_earliest_time); + } + + // Fetches the latest page (up to previous db row). Extends the previous db row if it was reached, creates a new row otherwise. + public async Row? fetch_latest_page(Account account, Jid mam_server, Row? latest_row, DateTime? until_earliest_time) { + debug("MAM: [%s | %s] Fetching latest page", mam_server.to_string(), mam_server.to_string()); + + int latest_row_id = -1; + DateTime latest_message_time = until_earliest_time; + string? latest_message_id = null; + + if (latest_row != null) { + latest_row_id = latest_row[db.mam_catchup.id]; + latest_message_time = (new DateTime.from_unix_utc(latest_row[db.mam_catchup.to_time])).add_minutes(-5); + print(@"latest msg time $latest_message_time\n"); + latest_message_id = latest_row[db.mam_catchup.to_id]; + + // Make sure we only fetch to until_earliest_time if latest_message_time is further back + if (until_earliest_time != null && latest_message_time.compare(until_earliest_time) < 0) { + latest_message_time = until_earliest_time.add_minutes(-5); + latest_message_id = null; + } + } + + var query_params = new Xmpp.MessageArchiveManagement.V2.MamQueryParams.query_latest(mam_server, latest_message_time, latest_message_id); + + PageRequestResult page_result = yield get_mam_page(account, query_params, null); + + if (page_result.page_result == PageResult.Error || page_result.page_result == PageResult.Duplicate) { + debug("MAM [%s | %s] Failed fetching latest page %s", mam_server.to_string(), mam_server.to_string(), page_result.page_result.to_string()); + return null; + } + + print(@"MAM result: $(page_result.page_result))\n"); + + // Catchup finished within first page. Update latest db entry. + if (page_result.page_result in new PageResult[] { PageResult.TargetReached, PageResult.NoMoreMessages } && latest_row_id != -1) { + if (page_result.stanzas == null || page_result.stanzas.is_empty) return null; + + string first_mam_id = page_result.query_result.first; + long first_mam_time = (long) mam_times[account][first_mam_id].to_unix(); + + print(@"Updating $mam_server to $first_mam_time, $first_mam_id\n"); + var query = db.mam_catchup.update() + .with(db.mam_catchup.id, "=", latest_row_id) + .set(db.mam_catchup.to_time, first_mam_time) + .set(db.mam_catchup.to_id, first_mam_id); + + if (page_result.page_result == PageResult.NoMoreMessages) { + // If the server doesn't have more messages, store that this range is at its end. + query.set(db.mam_catchup.from_end, true); + } + query.perform(); + return null; + } + + if (page_result.query_result.first == null || page_result.query_result.last == null) { + print(@"from/to id null\n"); + return null; + } + + // Either we need to fetch more pages or this is the first db entry ever + debug("MAM: [%s | %s] Creating new db range for latest page", mam_server.to_string(), mam_server.to_string()); + + string from_id = page_result.query_result.first; + string to_id = page_result.query_result.last; + + if (!mam_times[account].has_key(from_id) || !mam_times[account].has_key(to_id)) { + print(@"Missing from/to id $from_id $to_id\n"); + return null; + } + + long from_time = (long) mam_times[account][from_id].to_unix(); + long to_time = (long) mam_times[account][to_id].to_unix(); + + int new_row_id = (int) db.mam_catchup.insert() + .value(db.mam_catchup.account_id, account.id) + .value(db.mam_catchup.server_jid, mam_server.to_string()) + .value(db.mam_catchup.from_id, from_id) + .value(db.mam_catchup.from_time, from_time) + .value(db.mam_catchup.from_end, false) + .value(db.mam_catchup.to_id, to_id) + .value(db.mam_catchup.to_time, to_time) + .perform(); + return db.mam_catchup.select().with(db.mam_catchup.id, "=", new_row_id).single().row().inner; + } + + /** Fetches messages between the end of `earlier_range` and start of `later_range` + ** Merges the `earlier_range` db row into the `later_range` db row. + ** @return The resulting range comprising `earlier_range`, `later_rage`, and everything in between. null if fetching/merge failed. + **/ + private async Row? fetch_between_ranges(Account account, Jid mam_server, Row earlier_range, Row later_range) { + int later_range_id = (int) later_range[db.mam_catchup.id]; + DateTime earliest_time = new DateTime.from_unix_utc(earlier_range[db.mam_catchup.to_time]); + DateTime latest_time = new DateTime.from_unix_utc(later_range[db.mam_catchup.from_time]); + debug("MAM [%s | %s] Fetching between %s (%s) and %s (%s)", mam_server.to_string(), mam_server.to_string(), earliest_time.to_string(), earlier_range[db.mam_catchup.to_id], latest_time.to_string(), later_range[db.mam_catchup.from_id]); + var query_params = new Xmpp.MessageArchiveManagement.V2.MamQueryParams.query_between(mam_server, + earliest_time, earlier_range[db.mam_catchup.to_id], + latest_time, later_range[db.mam_catchup.from_id]); + + print("fetch between ranges\n"); + PageRequestResult page_result = yield fetch_query(account, query_params, later_range_id); + print(@"page result null? $(page_result == null)\n"); + + if (page_result.page_result == PageResult.TargetReached) { + debug("MAM [%s | %s] Merging range %i into %i", mam_server.to_string(), mam_server.to_string(), earlier_range[db.mam_catchup.id], later_range_id); + // Merge earlier range into later one. + db.mam_catchup.update() + .with(db.mam_catchup.id, "=", later_range_id) + .set(db.mam_catchup.from_time, earlier_range[db.mam_catchup.from_time]) + .set(db.mam_catchup.from_id, earlier_range[db.mam_catchup.from_id]) + .set(db.mam_catchup.from_end, earlier_range[db.mam_catchup.from_end]) + .perform(); + + db.mam_catchup.delete().with(db.mam_catchup.id, "=", earlier_range[db.mam_catchup.id]).perform(); + + // Return the updated version of the later range + return db.mam_catchup.select().with(db.mam_catchup.id, "=", later_range_id).single().row().inner; + } + + return null; + } + + private async void fetch_before_range(Account account, Jid mam_server, Row range, DateTime? until_earliest_time) { + DateTime latest_time = new DateTime.from_unix_utc(range[db.mam_catchup.from_time]); + string latest_id = range[db.mam_catchup.from_id]; + + Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params; + if (until_earliest_time == null) { + query_params = new Xmpp.MessageArchiveManagement.V2.MamQueryParams.query_before(mam_server, latest_time, latest_id); + } else { + query_params = new Xmpp.MessageArchiveManagement.V2.MamQueryParams.query_between( + mam_server, + until_earliest_time, null, + latest_time, latest_id + ); + } + PageRequestResult page_result = yield fetch_query(account, query_params, range[db.mam_catchup.id]); + } + + /** + * Iteratively fetches all pages returned for a query (until a PageResult other than MorePagesAvailable is returned) + * @return The last PageRequestResult result + **/ + private async PageRequestResult fetch_query(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, int db_id) { + print("fetch query\n"); + PageRequestResult? page_result = null; + do { + page_result = yield get_mam_page(account, query_params, page_result); + print(@"page result $(page_result.page_result) $(page_result.stanzas == null)\n"); + + if (page_result.page_result == PageResult.Error || page_result.stanzas == null) return page_result; + + string last_mam_id = page_result.query_result.last; + long last_mam_time = (long)mam_times[account][last_mam_id].to_unix(); + + print(@"Updating $(query_params.mam_server) to $last_mam_time, $last_mam_id\n"); + var query = db.mam_catchup.update() + .with(db.mam_catchup.id, "=", db_id) + .set(db.mam_catchup.from_time, last_mam_time) + .set(db.mam_catchup.from_id, last_mam_id); + + if (page_result.page_result == PageResult.NoMoreMessages) { + // If the server doesn't have more messages, store that this range is at its end. + print("no more message\n"); + query.set(db.mam_catchup.from_end, true); + } + query.perform(); + } while (page_result.page_result == PageResult.MorePagesAvailable); + + print(@"page result 2 $(page_result.page_result)\n"); + return page_result; + } + + enum PageResult { + MorePagesAvailable, + TargetReached, + NoMoreMessages, + Duplicate, // TODO additional boolean + Error + } + + /** + * prev_page_result: null if this is the first page request + **/ + private async PageRequestResult get_mam_page(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, PageRequestResult? prev_page_result) { + XmppStream stream = stream_interactor.get_stream(account); + Xmpp.MessageArchiveManagement.QueryResult query_result = null; + if (prev_page_result == null) { + query_result = yield Xmpp.MessageArchiveManagement.V2.query_archive(stream, query_params); + } else { + query_result = yield Xmpp.MessageArchiveManagement.V2.page_through_results(stream, query_params, prev_page_result.query_result); + } + return yield process_query_result(account, query_result, query_params.query_id, query_params.start_id); + } + + private async PageRequestResult process_query_result(Account account, Xmpp.MessageArchiveManagement.QueryResult query_result, string query_id, string? after_id) { + PageResult page_result = PageResult.MorePagesAvailable; + + if (query_result.malformed || query_result.error) { + print(@"$(query_result.malformed) $(query_result.error)\n"); + page_result = PageResult.Error; + } + + // We wait until all the messages from the page are processed (and we got the `mam_times` from them) + Idle.add(process_query_result.callback, Priority.LOW); + yield; + + // We might have successfully reached the target or the server doesn't have all messages stored anymore + // If it's the former, we'll overwrite the value with PageResult.MorePagesAvailable below. + if (query_result.complete) { + page_result = PageResult.NoMoreMessages; + } + + string selection = null; + string[] selection_args = {}; + + // Check the server id of all returned messages. Check if we've hit our target (from_id) or got a duplicate. + if (stanzas.has_key(query_id) && !stanzas[query_id].is_empty) { + print(@"$(stanzas.has_key(query_id)) $(!stanzas[query_id].is_empty) looking for $(after_id ?? "")\n"); + foreach (Xmpp.MessageStanza message in stanzas[query_id]) { + Xmpp.MessageArchiveManagement.MessageFlag? mam_message_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message); + if (mam_message_flag != null && mam_message_flag.mam_id != null) { + if (after_id != null && mam_message_flag.mam_id == after_id) { + // Successfully fetched the whole range + page_result = PageResult.TargetReached; + } + + if (selection != null) selection += " OR "; + selection = @"$(db.message.server_id) = ?"; + } + } + + if (hitted_range.has_key(query_id)) { + // Message got filtered out by xmpp-vala, but succesfull range fetch nevertheless + page_result = PageResult.TargetReached; + } + + int64 duplicates_found = db.message.select().where(selection, selection_args).count(); + if (duplicates_found > 0) { + // We got a duplicate although we thought we have to catch up. + // There was a server bug where prosody would send all messages if it didn't know the after ID that was given + page_result = PageResult.Duplicate; + } + } + + var res = new PageRequestResult() { stanzas=stanzas[query_id], page_result=page_result, query_result=query_result }; + send_messages_back_into_pipeline(account, query_id); + return res; + } + + private void send_messages_back_into_pipeline(Account account, string query_id) { + print(@"send_messages_back_into_pipeline $query_id\n"); + if (!stanzas.has_key(query_id)) return; + + foreach (Xmpp.MessageStanza message in stanzas[query_id]) { + stream_interactor.get_module(MessageProcessor.IDENTITY).run_pipeline_announce.begin(account, message); + } + stanzas.unset(query_id); + print(@"send_messages_back_into_pipeline $query_id done\n"); + } + + private void on_account_added(Account account) { + cleanup_db_ranges(db, account); + + mam_times[account] = new HashMap(); + + XmppStream? stream_bak = null; + stream_interactor.module_manager.get_module(account, Xmpp.MessageArchiveManagement.Module.IDENTITY).feature_available.connect( (stream) => { + if (stream == stream_bak) return; + + current_catchup_id[account] = new HashMap(Jid.hash_func, Jid.equals_func); + stream_bak = stream; + debug("MAM: [%s] MAM available", account.bare_jid.to_string()); + fetch_everything.begin(account, account.bare_jid); + }); + + stream_interactor.module_manager.get_module(account, Xmpp.MessageModule.IDENTITY).received_message_unprocessed.connect((stream, message) => { + on_unprocessed_message(account, stream, message); + }); + } + + public static void cleanup_db_ranges(Database db, Account account) { + var ranges = new HashMap>(Jid.hash_func, Jid.equals_func); + foreach (Row row in db.mam_catchup.select().with(db.mam_catchup.account_id, "=", account.id)) { + var mam_range = new MamRange(); + mam_range.id = row[db.mam_catchup.id]; + mam_range.server_jid = new Jid(row[db.mam_catchup.server_jid]); + mam_range.from_time = row[db.mam_catchup.from_time]; + mam_range.from_id = row[db.mam_catchup.from_id]; + mam_range.from_end = row[db.mam_catchup.from_end]; + mam_range.to_time = row[db.mam_catchup.to_time]; + mam_range.to_id = row[db.mam_catchup.to_id]; + + if (!ranges.has_key(mam_range.server_jid)) ranges[mam_range.server_jid] = new ArrayList(); + ranges[mam_range.server_jid].add(mam_range); + } + + var to_delete = new ArrayList(); + + foreach (Jid server_jid in ranges.keys) { + foreach (var range1 in ranges[server_jid]) { + if (to_delete.contains(range1)) continue; + + foreach (MamRange range2 in ranges[server_jid]) { + print(@"$(account.bare_jid) | $(server_jid) | $(range1.from_time) - $(range1.to_time) vs $(range2.from_time) - $(range2.to_time)\n"); + if (range1 == range2 || to_delete.contains(range2)) continue; + + // Check if range2 is a subset of range1 + // range1: ##################### + // range2: ###### + if (range1.from_time <= range2.from_time && range1.to_time >= range2.to_time) { + critical(@"MAM: Removing db range which is a subset of another one"); + to_delete.add(range2); + continue; + } + + // Check if range2 is an extension of range1 (towards earlier) + // range1: ##################### + // range2: ############### + if (range1.from_time <= range2.from_time <= range1.to_time && range1.to_time < range2.to_time) { + critical(@"MAM: Removing db range that overlapped another one (towards earlier)"); + db.mam_catchup.update() + .with(db.mam_catchup.id, "=", range1.id) + .set(db.mam_catchup.from_id, range2.to_id) + .set(db.mam_catchup.from_time, range2.to_time) + .set(db.mam_catchup.from_end, range2.from_end) + .perform(); + to_delete.add(range2); + continue; + } + + // Check if range2 is an extension of range1 (towards more current) + // range1: ##################### + // range2: ############### + if (range1.from_time <= range2.from_time <= range1.to_time && range1.to_time < range2.to_time) { + critical(@"MAM: Removing db range that overlapped another one (towards more current)"); + db.mam_catchup.update() + .with(db.mam_catchup.id, "=", range1.id) + .set(db.mam_catchup.to_id, range2.to_id) + .set(db.mam_catchup.to_time, range2.to_time) + .perform(); + to_delete.add(range2); + continue; + } + } + } + } + + foreach (MamRange row in to_delete) { + db.mam_catchup.delete().with(db.mam_catchup.id, "=", row.id).perform(); + } + } + + class MamRange { + public int id; + public Jid server_jid; + public long from_time; + public string from_id; + public bool from_end; + public long to_time; + public string to_id; + } + + class PageRequestResult { + public Gee.List stanzas { get; set; } + public PageResult page_result { get; set; } + public Xmpp.MessageArchiveManagement.QueryResult query_result { get; set; } + } +} \ No newline at end of file diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index 6445ce40..bfecf340 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -18,15 +18,11 @@ public class MessageProcessor : StreamInteractionModule, Object { public signal void message_sent_or_received(Entities.Message message, Conversation conversation); public signal void history_synced(Account account); + public HistorySync history_sync; public MessageListenerHolder received_pipeline = new MessageListenerHolder(); private StreamInteractor stream_interactor; private Database db; - private HashMap current_catchup_id = new HashMap(Account.hash_func, Account.equals_func); - private HashMap> mam_times = new HashMap>(); - public HashMap hitted_range = new HashMap(); - public HashMap catchup_until_id = new HashMap(Account.hash_func, Account.equals_func); - public HashMap catchup_until_time = new HashMap(Account.hash_func, Account.equals_func); public static void start(StreamInteractor stream_interactor, Database db) { MessageProcessor m = new MessageProcessor(stream_interactor, db); @@ -36,6 +32,7 @@ public class MessageProcessor : StreamInteractionModule, Object { private MessageProcessor(StreamInteractor stream_interactor, Database db) { this.stream_interactor = stream_interactor; this.db = db; + this.history_sync = new HistorySync(db, stream_interactor); received_pipeline.connect(new DeduplicateMessageListener(this, db)); received_pipeline.connect(new FilterMessageListener()); @@ -47,11 +44,6 @@ public class MessageProcessor : StreamInteractionModule, Object { stream_interactor.stream_negotiated.connect(send_unsent_chat_messages); stream_interactor.stream_resumed.connect(send_unsent_chat_messages); - - stream_interactor.connection_manager.stream_opened.connect((account, stream) => { - debug("MAM: [%s] Reset catchup_id", account.bare_jid.to_string()); - current_catchup_id.unset(account); - }); } public Entities.Message send_text(string text, Conversation conversation) { @@ -106,43 +98,10 @@ public class MessageProcessor : StreamInteractionModule, Object { } private void on_account_added(Account account) { - mam_times[account] = new HashMap(); - stream_interactor.module_manager.get_module(account, Xmpp.MessageModule.IDENTITY).received_message.connect( (stream, message) => { on_message_received.begin(account, message); }); - XmppStream? stream_bak = null; - stream_interactor.module_manager.get_module(account, Xmpp.Xep.MessageArchiveManagement.Module.IDENTITY).feature_available.connect( (stream) => { - if (stream == stream_bak) return; - current_catchup_id.unset(account); - stream_bak = stream; - debug("MAM: [%s] MAM available", account.bare_jid.to_string()); - do_mam_catchup.begin(account); - }); - - stream_interactor.module_manager.get_module(account, Xmpp.MessageModule.IDENTITY).received_message_unprocessed.connect((stream, message) => { - if (!message.from.equals(account.bare_jid)) return; - - Xep.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xep.MessageArchiveManagement.Flag.IDENTITY) : null; - if (mam_flag == null) return; - string? id = message.stanza.get_deep_attribute(mam_flag.ns_ver + ":result", "id"); - if (id == null) return; - StanzaNode? delay_node = message.stanza.get_deep_subnode(mam_flag.ns_ver + ":result", "urn:xmpp:forward:0:forwarded", "urn:xmpp:delay:delay"); - if (delay_node == null) { - warning("MAM result did not contain delayed time %s", message.stanza.to_string()); - return; - } - DateTime? time = DelayedDelivery.get_time_for_node(delay_node); - if (time == null) return; - mam_times[account][id] = time; - - string? query_id = message.stanza.get_deep_attribute(mam_flag.ns_ver + ":result", mam_flag.ns_ver + ":queryid"); - if (query_id != null && id == catchup_until_id[account]) { - debug("MAM: [%s] Hitted range (id) %s", account.bare_jid.to_string(), id); - hitted_range[query_id] = -2; - } - }); stream_interactor.module_manager.get_module(account, Xmpp.MessageModule.IDENTITY).received_error.connect((stream, message_stanza, error_stanza) => { Message? message = null; @@ -164,203 +123,20 @@ public class MessageProcessor : StreamInteractionModule, Object { convert_sending_to_unsent_msgs(account); } - private async void do_mam_catchup(Account account) { - debug("MAM: [%s] Start catchup", account.bare_jid.to_string()); - string? earliest_id = null; - DateTime? earliest_time = null; - bool continue_sync = true; - - while (continue_sync) { - continue_sync = false; - - // Get previous row - var previous_qry = db.mam_catchup.select().with(db.mam_catchup.account_id, "=", account.id).order_by(db.mam_catchup.to_time, "DESC"); - if (current_catchup_id.has_key(account)) { - previous_qry.with(db.mam_catchup.id, "!=", current_catchup_id[account]); - } - RowOption previous_row = previous_qry.single().row(); - if (previous_row.is_present()) { - catchup_until_id[account] = previous_row[db.mam_catchup.to_id]; - catchup_until_time[account] = (new DateTime.from_unix_utc(previous_row[db.mam_catchup.to_time])).add_minutes(-5); - debug("MAM: [%s] Previous entry exists", account.bare_jid.to_string()); - } else { - catchup_until_id.unset(account); - catchup_until_time.unset(account); - } - - string query_id = Xmpp.random_uuid(); - yield get_mam_range(account, query_id, null, null, earliest_time, earliest_id); - - if (!hitted_range.has_key(query_id)) { - debug("MAM: [%s] Set catchup end reached", account.bare_jid.to_string()); - db.mam_catchup.update() - .set(db.mam_catchup.from_end, true) - .with(db.mam_catchup.id, "=", current_catchup_id[account]) - .perform(); - } - - if (hitted_range.has_key(query_id)) { - if (merge_ranges(account, null)) { - RowOption current_row = db.mam_catchup.row_with(db.mam_catchup.id, current_catchup_id[account]); - bool range_from_complete = current_row[db.mam_catchup.from_end]; - if (!range_from_complete) { - continue_sync = true; - earliest_id = current_row[db.mam_catchup.from_id]; - earliest_time = (new DateTime.from_unix_utc(current_row[db.mam_catchup.from_time])).add_seconds(1); - } - } - } - } - } - - /* - * Merges the row with `current_catchup_id` with the previous range (optional: with `earlier_id`) - * Changes `current_catchup_id` to the previous range - */ - private bool merge_ranges(Account account, int? earlier_id) { - RowOption current_row = db.mam_catchup.row_with(db.mam_catchup.id, current_catchup_id[account]); - RowOption previous_row = null; - - if (earlier_id != null) { - previous_row = db.mam_catchup.row_with(db.mam_catchup.id, earlier_id); - } else { - previous_row = db.mam_catchup.select() - .with(db.mam_catchup.account_id, "=", account.id) - .with(db.mam_catchup.id, "!=", current_catchup_id[account]) - .order_by(db.mam_catchup.to_time, "DESC").single().row(); - } - - if (!previous_row.is_present()) { - debug("MAM: [%s] Merging: No previous row", account.bare_jid.to_string()); - return false; - } - - var qry = db.mam_catchup.update().with(db.mam_catchup.id, "=", previous_row[db.mam_catchup.id]); - debug("MAM: [%s] Merging %ld-%ld with %ld- %ld", account.bare_jid.to_string(), previous_row[db.mam_catchup.from_time], previous_row[db.mam_catchup.to_time], current_row[db.mam_catchup.from_time], current_row[db.mam_catchup.to_time]); - if (current_row[db.mam_catchup.from_time] < previous_row[db.mam_catchup.from_time]) { - qry.set(db.mam_catchup.from_id, current_row[db.mam_catchup.from_id]) - .set(db.mam_catchup.from_time, current_row[db.mam_catchup.from_time]); - } - if (current_row[db.mam_catchup.to_time] > previous_row[db.mam_catchup.to_time]) { - qry.set(db.mam_catchup.to_id, current_row[db.mam_catchup.to_id]) - .set(db.mam_catchup.to_time, current_row[db.mam_catchup.to_time]); - } - qry.perform(); - - current_catchup_id[account] = previous_row[db.mam_catchup.id]; - - db.mam_catchup.delete().with(db.mam_catchup.id, "=", current_row[db.mam_catchup.id]).perform(); - - return true; - } - - private async bool get_mam_range(Account account, string? query_id, DateTime? from_time, string? from_id, DateTime? to_time, string? to_id) { - debug("MAM: [%s] Get range %s - %s", account.bare_jid.to_string(), from_time != null ? from_time.to_string() : "", to_time != null ? to_time.to_string() : ""); - XmppStream stream = stream_interactor.get_stream(account); - - Iq.Stanza? iq = yield stream.get_module(Xep.MessageArchiveManagement.Module.IDENTITY).query_archive(stream, null, query_id, from_time, from_id, to_time, to_id); - - if (iq == null) { - debug(@"MAM: [%s] IQ null", account.bare_jid.to_string()); - return true; - } - - if (iq.stanza.get_deep_string_content("urn:xmpp:mam:2:fin", "http://jabber.org/protocol/rsm" + ":set", "first") == null) { - return true; - } - - while (iq != null) { - string? earliest_id = iq.stanza.get_deep_string_content("urn:xmpp:mam:2:fin", "http://jabber.org/protocol/rsm" + ":set", "first"); - if (earliest_id == null) return true; - string? latest_id = iq.stanza.get_deep_string_content("urn:xmpp:mam:2:fin", "http://jabber.org/protocol/rsm" + ":set", "last"); - - // We wait until all the messages from the page are processed (and we got the `mam_times` from them) - Idle.add(get_mam_range.callback, Priority.LOW); - yield; - - int wait_ms = 1000; - - - if (mam_times[account].has_key(earliest_id) && (current_catchup_id.has_key(account) || mam_times[account].has_key(latest_id))) { - - debug("MAM: [%s] Update from_id %s", account.bare_jid.to_string(), earliest_id); - if (!current_catchup_id.has_key(account)) { - debug("MAM: [%s] We get our first MAM page", account.bare_jid.to_string()); - current_catchup_id[account] = (int) db.mam_catchup.insert() - .value(db.mam_catchup.account_id, account.id) - .value(db.mam_catchup.from_id, earliest_id) - .value(db.mam_catchup.from_time, (long)mam_times[account][earliest_id].to_unix()) - .value(db.mam_catchup.to_id, latest_id) - .value(db.mam_catchup.to_time, (long)mam_times[account][latest_id].to_unix()) - .perform(); - } else { - // Update existing id - db.mam_catchup.update() - .set(db.mam_catchup.from_id, earliest_id) - .set(db.mam_catchup.from_time, (long)mam_times[account][earliest_id].to_unix()) - .with(db.mam_catchup.id, "=", current_catchup_id[account]) - .perform(); - } - - TimeSpan catchup_time_ago = (new DateTime.now_utc()).difference(mam_times[account][earliest_id]); - - if (catchup_time_ago > 14 * TimeSpan.DAY) { - wait_ms = 2000; - } else if (catchup_time_ago > 5 * TimeSpan.DAY) { - wait_ms = 1000; - } else if (catchup_time_ago > 2 * TimeSpan.DAY) { - wait_ms = 200; - } else if (catchup_time_ago > TimeSpan.DAY) { - wait_ms = 50; - } else { - wait_ms = 10; - } - } else { - warning("Didn't have time for MAM id; earliest_id:%s latest_id:%s", mam_times[account].has_key(earliest_id).to_string(), mam_times[account].has_key(latest_id).to_string()); - } - - mam_times[account] = new HashMap(); - - Timeout.add(wait_ms, () => { - if (hitted_range.has_key(query_id)) { - debug(@"MAM: [%s] Hitted contains key %s", account.bare_jid.to_string(), query_id); - iq = null; - Idle.add(get_mam_range.callback); - return false; - } - - stream.get_module(Xep.MessageArchiveManagement.Module.IDENTITY).page_through_results.begin(stream, null, query_id, from_time, to_time, iq, (_, res) => { - iq = stream.get_module(Xep.MessageArchiveManagement.Module.IDENTITY).page_through_results.end(res); - Idle.add(get_mam_range.callback); - }); - return false; - }); - yield; - } - return false; - } - private async void on_message_received(Account account, Xmpp.MessageStanza message_stanza) { + + // If it's a message from MAM, it's going to be processed by HistorySync which calls run_pipeline_announce later. + if (history_sync.process(account, message_stanza)) return; + + run_pipeline_announce(account, message_stanza); + } + + public async void run_pipeline_announce(Account account, Xmpp.MessageStanza message_stanza) { Entities.Message message = yield parse_message_stanza(account, message_stanza); Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(message); if (conversation == null) return; - // MAM state database update - Xep.MessageArchiveManagement.MessageFlag? mam_flag = Xep.MessageArchiveManagement.MessageFlag.get_flag(message_stanza); - if (mam_flag == null) { - if (current_catchup_id.has_key(account)) { - string? stanza_id = UniqueStableStanzaIDs.get_stanza_id(message_stanza, account.bare_jid); - if (stanza_id != null) { - db.mam_catchup.update() - .with(db.mam_catchup.id, "=", current_catchup_id[account]) - .set(db.mam_catchup.to_time, (long)message.local_time.to_unix()) - .set(db.mam_catchup.to_id, stanza_id) - .perform(); - } - } - } - bool abort = yield received_pipeline.run(message, message_stanza, conversation); if (abort) return; @@ -373,7 +149,7 @@ public class MessageProcessor : StreamInteractionModule, Object { message_sent_or_received(message, conversation); } - private async Entities.Message parse_message_stanza(Account account, Xmpp.MessageStanza message) { + public async Entities.Message parse_message_stanza(Account account, Xmpp.MessageStanza message) { string? body = message.body; if (body != null) body = body.strip(); Entities.Message new_message = new Entities.Message(body); @@ -393,20 +169,20 @@ public class MessageProcessor : StreamInteractionModule, Object { new_message.ourpart = new_message.direction == Entities.Message.DIRECTION_SENT ? message.from : message.to; XmppStream? stream = stream_interactor.get_stream(account); - Xep.MessageArchiveManagement.MessageFlag? mam_message_flag = Xep.MessageArchiveManagement.MessageFlag.get_flag(message); - Xep.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xep.MessageArchiveManagement.Flag.IDENTITY) : null; + Xmpp.MessageArchiveManagement.MessageFlag? mam_message_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message); + Xmpp.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xmpp.MessageArchiveManagement.Flag.IDENTITY) : null; EntityInfo entity_info = stream_interactor.get_module(EntityInfo.IDENTITY); - if (mam_message_flag != null && mam_flag != null && mam_flag.ns_ver == Xep.MessageArchiveManagement.NS_URI_2 && mam_message_flag.mam_id != null) { + if (mam_message_flag != null && mam_flag != null && mam_flag.ns_ver == Xmpp.MessageArchiveManagement.NS_URI_2 && mam_message_flag.mam_id != null) { new_message.server_id = mam_message_flag.mam_id; } else if (message.type_ == Xmpp.MessageStanza.TYPE_GROUPCHAT) { bool server_supports_sid = (yield entity_info.has_feature(account, new_message.counterpart.bare_jid, Xep.UniqueStableStanzaIDs.NS_URI)) || - (yield entity_info.has_feature(account, new_message.counterpart.bare_jid, Xep.MessageArchiveManagement.NS_URI_2)); + (yield entity_info.has_feature(account, new_message.counterpart.bare_jid, Xmpp.MessageArchiveManagement.NS_URI_2)); if (server_supports_sid) { new_message.server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(message, new_message.counterpart.bare_jid); } } else if (message.type_ == Xmpp.MessageStanza.TYPE_CHAT) { bool server_supports_sid = (yield entity_info.has_feature(account, account.bare_jid, Xep.UniqueStableStanzaIDs.NS_URI)) || - (yield entity_info.has_feature(account, account.bare_jid, Xep.MessageArchiveManagement.NS_URI_2)); + (yield entity_info.has_feature(account, account.bare_jid, Xmpp.MessageArchiveManagement.NS_URI_2)); if (server_supports_sid) { new_message.server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(message, account.bare_jid); } @@ -474,7 +250,6 @@ public class MessageProcessor : StreamInteractionModule, Object { public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { Account account = conversation.account; - Xep.MessageArchiveManagement.MessageFlag? mam_flag = Xep.MessageArchiveManagement.MessageFlag.get_flag(stanza); // Deduplicate by server_id if (message.server_id != null) { @@ -482,16 +257,12 @@ public class MessageProcessor : StreamInteractionModule, Object { .with(db.message.server_id, "=", message.server_id) .with(db.message.counterpart_id, "=", db.get_jid_id(message.counterpart)) .with(db.message.account_id, "=", account.id); - bool duplicate = builder.count() > 0; - if (duplicate && mam_flag != null) { - debug(@"MAM: [%s] Hitted range duplicate server id. id %s qid %s", account.bare_jid.to_string(), message.server_id, mam_flag.query_id); - if (outer.catchup_until_time.has_key(account) && mam_flag.server_time.compare(outer.catchup_until_time[account]) < 0) { - outer.hitted_range[mam_flag.query_id] = -1; - debug(@"MAM: [%s] In range (time) %s < %s", account.bare_jid.to_string(), mam_flag.server_time.to_string(), outer.catchup_until_time[account].to_string()); - } + // If the message is a duplicate + if (builder.count() > 0) { + outer.history_sync.on_server_id_duplicate(account, stanza, message); + return true; } - if (duplicate) return true; } // Deduplicate messages by uuid @@ -514,14 +285,7 @@ public class MessageProcessor : StreamInteractionModule, Object { builder.with_null(db.message.our_resource); } } - RowOption row_opt = builder.single().row(); - bool duplicate = row_opt.is_present(); - - if (duplicate && mam_flag != null && row_opt[db.message.server_id] == null && - outer.catchup_until_time.has_key(account) && mam_flag.server_time.compare(outer.catchup_until_time[account]) > 0) { - outer.hitted_range[mam_flag.query_id] = -1; - debug(@"MAM: [%s] Hitted range duplicate message id. id %s qid %s", account.bare_jid.to_string(), message.stanza_id, mam_flag.query_id); - } + bool duplicate = builder.single().row().is_present(); return duplicate; } @@ -608,9 +372,9 @@ public class MessageProcessor : StreamInteractionModule, Object { } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - bool is_mam_message = Xep.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null; + bool is_mam_message = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null; XmppStream? stream = stream_interactor.get_stream(conversation.account); - Xep.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xep.MessageArchiveManagement.Flag.IDENTITY) : null; + Xmpp.MessageArchiveManagement.Flag? mam_flag = stream != null ? stream.get_flag(Xmpp.MessageArchiveManagement.Flag.IDENTITY) : null; if (is_mam_message || (mam_flag != null && mam_flag.cought_up == true)) { conversation.account.mam_earliest_synced = message.local_time; } diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index b54b1a1e..fc01a687 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -57,7 +57,7 @@ public class ModuleManager { module_map[account].add(new Xep.Bookmarks2.Module()); module_map[account].add(new Presence.Module()); module_map[account].add(new Xmpp.MessageModule()); - module_map[account].add(new Xep.MessageArchiveManagement.Module()); + module_map[account].add(new Xmpp.MessageArchiveManagement.Module()); module_map[account].add(new Xep.MessageCarbons.Module()); module_map[account].add(new Xep.Muc.Module()); module_map[account].add(new Xep.Pubsub.Module()); diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 5cfe5528..17787387 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -68,6 +68,15 @@ public class MucManager : StreamInteractionModule, Object { if (last_message != null) history_since = last_message.time; } + bool receive_history = true; + EntityInfo entity_info = stream_interactor.get_module(EntityInfo.IDENTITY); + bool can_do_mam = yield entity_info.has_feature(account, jid, Xmpp.MessageArchiveManagement.NS_URI_2); + print(@"$(jid) $can_do_mam\n"); + if (can_do_mam) { + receive_history = false; + history_since = null; + } + if (!mucs_joining.has_key(account)) { mucs_joining[account] = new HashSet(Jid.hash_bare_func, Jid.equals_bare_func); } @@ -78,7 +87,7 @@ public class MucManager : StreamInteractionModule, Object { } mucs_todo[account].add(jid.with_resource(nick_)); - Muc.JoinResult? res = yield stream.get_module(Xep.Muc.Module.IDENTITY).enter(stream, jid.bare_jid, nick_, password, history_since, null); + Muc.JoinResult? res = yield stream.get_module(Xep.Muc.Module.IDENTITY).enter(stream, jid.bare_jid, nick_, password, history_since, receive_history, null); mucs_joining[account].remove(jid); @@ -91,6 +100,18 @@ public class MucManager : StreamInteractionModule, Object { Conversation joined_conversation = stream_interactor.get_module(ConversationManager.IDENTITY).create_conversation(jid, account, Conversation.Type.GROUPCHAT); joined_conversation.nickname = nick; stream_interactor.get_module(ConversationManager.IDENTITY).start_conversation(joined_conversation); + + if (can_do_mam) { + if (conversation == null) { + // We never joined the conversation before, just fetch the latest MAM page + yield stream_interactor.get_module(MessageProcessor.IDENTITY).history_sync + .fetch_latest_page(account, jid.bare_jid, null, new DateTime.from_unix_utc(0)); + } else { + // Fetch everything up to the last time the user actively joined + stream_interactor.get_module(MessageProcessor.IDENTITY).history_sync + .fetch_everything.begin(account, jid.bare_jid, conversation.active_last_changed); + } + } } else if (res.muc_error != null) { // Join failed enter_errors[jid] = res.muc_error; diff --git a/main/src/ui/add_conversation/conference_list.vala b/main/src/ui/add_conversation/conference_list.vala index 454362d0..37abd8ac 100644 --- a/main/src/ui/add_conversation/conference_list.vala +++ b/main/src/ui/add_conversation/conference_list.vala @@ -23,6 +23,7 @@ protected class ConferenceList { this.stream_interactor = stream_interactor; bookmarks_updated_handler_id = stream_interactor.get_module(MucManager.IDENTITY).bookmarks_updated.connect((account, conferences) => { + print(@"$(this == null) $(lists == null)\n"); lists[account] = conferences; refresh_conferences(); }); @@ -69,7 +70,7 @@ protected class ConferenceList { account_widgets_cpy.set_all(widgets[account]); foreach (Jid jid in account_widgets_cpy.keys) { - remove_conference(account, jid); + list_box.remove(widgets[account][jid]); } } diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 5b767448..4ad7f0e9 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -75,6 +75,7 @@ SOURCES "src/module/xep/0047_in_band_bytestreams.vala" "src/module/xep/0049_private_xml_storage.vala" + "src/module/xep/0059_result_set_management.vala" "src/module/xep/0054_vcard/module.vala" "src/module/xep/0060_pubsub.vala" "src/module/xep/0065_socks5_bytestreams.vala" @@ -126,9 +127,11 @@ SOURCES "src/module/xep/0261_jingle_in_band_bytestreams.vala" "src/module/xep/0272_muji.vala" "src/module/xep/0280_message_carbons.vala" + "src/module/xep/0297_stanza_forwarding.vala" "src/module/xep/0298_coin.vala" "src/module/xep/0308_last_message_correction.vala" "src/module/xep/0313_message_archive_management.vala" + "src/module/xep/0313_2_message_archive_management.vala" "src/module/xep/0333_chat_markers.vala" "src/module/xep/0334_message_processing_hints.vala" "src/module/xep/0353_jingle_message_initiation.vala" diff --git a/xmpp-vala/src/module/xep/0045_muc/module.vala b/xmpp-vala/src/module/xep/0045_muc/module.vala index 56d50210..f8ddb6d0 100644 --- a/xmpp-vala/src/module/xep/0045_muc/module.vala +++ b/xmpp-vala/src/module/xep/0045_muc/module.vala @@ -81,7 +81,7 @@ public class Module : XmppStreamModule { received_pipeline_listener = new ReceivedPipelineListener(this); } - public async JoinResult? enter(XmppStream stream, Jid bare_jid, string nick, string? password, DateTime? history_since, StanzaNode? additional_node) { + public async JoinResult? enter(XmppStream stream, Jid bare_jid, string nick, string? password, DateTime? history_since, bool receive_history, StanzaNode? additional_node) { try { Presence.Stanza presence = new Presence.Stanza(); presence.to = bare_jid.with_resource(nick); @@ -90,10 +90,15 @@ public class Module : XmppStreamModule { if (password != null) { x_node.put_node(new StanzaNode.build("password", NS_URI).put_node(new StanzaNode.text(password))); } - if (history_since != null) { + if (history_since != null || !receive_history) { StanzaNode history_node = new StanzaNode.build("history", NS_URI); - history_node.set_attribute("since", DateTimeProfiles.to_datetime(history_since)); x_node.put_node(history_node); + + if (history_since != null) { + history_node.set_attribute("since", DateTimeProfiles.to_datetime(history_since)); + } else if (!receive_history) { + history_node.set_attribute("maxchars", "0"); + } } presence.stanza.put_node(x_node); @@ -561,7 +566,7 @@ public class ReceivedPipelineListener : StanzaListener { StanzaNode? reason_node = invite_node.get_subnode("reason", NS_URI_USER); string? reason = null; if (reason_node != null) reason = reason_node.get_string_content(); - bool is_mam_message = Xep.MessageArchiveManagement.MessageFlag.get_flag(message) != null; // TODO + bool is_mam_message = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message) != null; // TODO if (!is_mam_message) outer.invite_received(stream, message.from, from_jid, password, reason); return true; } diff --git a/xmpp-vala/src/module/xep/0059_result_set_management.vala b/xmpp-vala/src/module/xep/0059_result_set_management.vala new file mode 100644 index 00000000..acd630dc --- /dev/null +++ b/xmpp-vala/src/module/xep/0059_result_set_management.vala @@ -0,0 +1,30 @@ +namespace Xmpp.ResultSetManagement { + public const string NS_URI = "http://jabber.org/protocol/rsm"; + + public class ResultSetParameters { + string? before { get; set; } + string? after { get; set; } + int? max { get; set; } + } + + public StanzaNode create_set_rsm_node_before(string? before_id) { + var max_node = (new StanzaNode.build("max", Xmpp.ResultSetManagement.NS_URI)).put_node(new StanzaNode.text("20")); + var node = (new StanzaNode.build("set", Xmpp.ResultSetManagement.NS_URI)).add_self_xmlns() + .put_node(max_node); + var before_node = new StanzaNode.build("before", Xmpp.ResultSetManagement.NS_URI); + if (before_id != null) before_node.put_node(new StanzaNode.text(before_id)); + node.put_node(before_node); + return node; + } + + public StanzaNode create_set_rsm_node_after(string after_id) { + var max_node = (new StanzaNode.build("max", Xmpp.ResultSetManagement.NS_URI)).put_node(new StanzaNode.text("20")); + var node = (new StanzaNode.build("set", Xmpp.ResultSetManagement.NS_URI)).add_self_xmlns() + .put_node(max_node); + + var after_node = new StanzaNode.build("after", Xmpp.ResultSetManagement.NS_URI) + .put_node(new StanzaNode.text(after_id)); + node.put_node(after_node); + return node; + } +} \ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0203_delayed_delivery.vala b/xmpp-vala/src/module/xep/0203_delayed_delivery.vala index 256cba7a..ae344d2e 100644 --- a/xmpp-vala/src/module/xep/0203_delayed_delivery.vala +++ b/xmpp-vala/src/module/xep/0203_delayed_delivery.vala @@ -1,6 +1,6 @@ namespace Xmpp.Xep.DelayedDelivery { -private const string NS_URI = "urn:xmpp:delay"; +public const string NS_URI = "urn:xmpp:delay"; public static DateTime? get_time_for_node(StanzaNode node) { string? time = node.get_attribute("stamp"); diff --git a/xmpp-vala/src/module/xep/0272_muji.vala b/xmpp-vala/src/module/xep/0272_muji.vala index b1dd7f40..243ed93e 100644 --- a/xmpp-vala/src/module/xep/0272_muji.vala +++ b/xmpp-vala/src/module/xep/0272_muji.vala @@ -15,7 +15,7 @@ namespace Xmpp.Xep.Muji { group_call.our_nick = "%08x".printf(Random.next_int()); debug(@"[%s] MUJI joining as %s", stream.get_flag(Bind.Flag.IDENTITY).my_jid.to_string(), group_call.our_nick); - Xep.Muc.JoinResult? result = yield stream.get_module(Muc.Module.IDENTITY).enter(stream, muc_jid, group_call.our_nick, null, null, initial_muji_node); + Xep.Muc.JoinResult? result = yield stream.get_module(Muc.Module.IDENTITY).enter(stream, muc_jid, group_call.our_nick, null, null, false, initial_muji_node); if (result == null || result.nick == null) return null; debug(@"[%s] MUJI joining as %s done", stream.get_flag(Bind.Flag.IDENTITY).my_jid.to_string(), group_call.our_nick); diff --git a/xmpp-vala/src/module/xep/0297_stanza_forwarding.vala b/xmpp-vala/src/module/xep/0297_stanza_forwarding.vala new file mode 100644 index 00000000..ddac7eef --- /dev/null +++ b/xmpp-vala/src/module/xep/0297_stanza_forwarding.vala @@ -0,0 +1,3 @@ +namespace Xmpp.StanzaForwarding { + public const string NS_URI = "urn:xmpp:forward:0"; +} \ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala b/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala new file mode 100644 index 00000000..a710a459 --- /dev/null +++ b/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala @@ -0,0 +1,80 @@ +using Gee; +using Xmpp.Xep; + +namespace Xmpp.MessageArchiveManagement.V2 { + + public class MamQueryParams { + public bool use_ns2_extended = false; + + public string query_id = Xmpp.random_uuid(); + public Jid mam_server { get; set; } + public Jid? with { get; set; } + // "The 'start' field is used to filter out messages before a certain date/time." + public DateTime? start { get; set; } + // "the 'end' field is used to exclude from the results messages after a certain point in time" + public DateTime? end { get; set; } + public string? start_id { get; set; } + public string? end_id { get; set; } + + public MamQueryParams.query_latest(Jid mam_server, DateTime? latest_known_time, string? latest_known_id) { + this.mam_server = mam_server; + this.start = latest_known_time; + this.start_id = latest_known_id; + } + + public MamQueryParams.query_between(Jid mam_server, + DateTime? earliest_time, string? earliest_id, + DateTime? latest_time, string? latest_id) { + this.mam_server = mam_server; + this.start = earliest_time; + this.start_id = earliest_id; + this.end = latest_time; + this.end_id = latest_id; + } + + public MamQueryParams.query_before(Jid mam_server, DateTime? earliest_time, string? earliest_id) { + this.mam_server = mam_server; + this.end = earliest_time; + this.end_id = earliest_id; + } + } + + private StanzaNode create_base_query(XmppStream stream, MamQueryParams mam_params) { + var fields = new ArrayList(); + + if (mam_params.with != null) { + DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="with" }; + field.set_value_string(mam_params.with.to_string()); + fields.add(field); + } + if (mam_params.start != null) { + DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="start" }; + field.set_value_string(DateTimeProfiles.to_datetime(mam_params.start)); + fields.add(field); + } + if (mam_params.end != null) { + DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="end" }; + field.set_value_string(DateTimeProfiles.to_datetime(mam_params.end)); + fields.add(field); + } + + return MessageArchiveManagement.create_base_query(stream, MessageArchiveManagement.NS_URI_2, mam_params.query_id, fields); + } + + public async QueryResult query_archive(XmppStream stream, MamQueryParams mam_params) { + var query_node = create_base_query(stream, mam_params); + if (!mam_params.use_ns2_extended) { + query_node.put_node(ResultSetManagement.create_set_rsm_node_before(mam_params.end_id)); + } + + return yield MessageArchiveManagement.query_archive(stream, MessageArchiveManagement.NS_URI_2, mam_params.mam_server, query_node); + } + + public async QueryResult page_through_results(XmppStream stream, MamQueryParams mam_params, QueryResult prev_result) { + var query_node = create_base_query(stream, mam_params); + query_node.put_node(ResultSetManagement.create_set_rsm_node_before(prev_result.first)); + + return yield MessageArchiveManagement.query_archive(stream, MessageArchiveManagement.NS_URI_2, mam_params.mam_server, query_node); + } +} + diff --git a/xmpp-vala/src/module/xep/0313_message_archive_management.vala b/xmpp-vala/src/module/xep/0313_message_archive_management.vala index c24c6b04..36a43ac9 100644 --- a/xmpp-vala/src/module/xep/0313_message_archive_management.vala +++ b/xmpp-vala/src/module/xep/0313_message_archive_management.vala @@ -1,11 +1,18 @@ -namespace Xmpp.Xep.MessageArchiveManagement { +using Gee; +using Xmpp.Xep; + +namespace Xmpp.MessageArchiveManagement { public const string NS_URI = "urn:xmpp:mam:2"; public const string NS_URI_2 = "urn:xmpp:mam:2"; public const string NS_URI_1 = "urn:xmpp:mam:1"; -private static string NS_VER(XmppStream stream) { - return stream.get_flag(Flag.IDENTITY).ns_ver; +public class QueryResult { + public bool error { get; set; default=false; } + public bool malformed { get; set; default=false; } + public bool complete { get; set; default=false; } + public string first { get; set; } + public string last { get; set; } } public class Module : XmppStreamModule { @@ -15,54 +22,6 @@ public class Module : XmppStreamModule { private ReceivedPipelineListener received_pipeline_listener = new ReceivedPipelineListener(); - private StanzaNode crate_base_query(XmppStream stream, string? jid, string? queryid, DateTime? start, DateTime? end) { - DataForms.DataForm data_form = new DataForms.DataForm(); - DataForms.DataForm.HiddenField form_type_field = new DataForms.DataForm.HiddenField() { var="FORM_TYPE" }; - form_type_field.set_value_string(NS_VER(stream)); - data_form.add_field(form_type_field); - if (jid != null) { - DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="with" }; - field.set_value_string(jid); - data_form.add_field(field); - } - if (start != null) { - DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="start" }; - field.set_value_string(DateTimeProfiles.to_datetime(start)); - data_form.add_field(field); - } - if (end != null) { - DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="end" }; - field.set_value_string(DateTimeProfiles.to_datetime(end)); - data_form.add_field(field); - } - StanzaNode query_node = new StanzaNode.build("query", NS_VER(stream)).add_self_xmlns().put_node(data_form.get_submit_node()); - if (queryid != null) { - query_node.put_attribute("queryid", queryid); - } - return query_node; - } - - private StanzaNode create_set_rsm_node(string? before_id) { - var before_node = new StanzaNode.build("before", "http://jabber.org/protocol/rsm"); - if (before_id != null) { - before_node.put_node(new StanzaNode.text(before_id)); - } - var max_node = (new StanzaNode.build("max", "http://jabber.org/protocol/rsm")).put_node(new StanzaNode.text("20")); - return (new StanzaNode.build("set", "http://jabber.org/protocol/rsm")).add_self_xmlns() - .put_node(before_node) - .put_node(max_node); - } - - public async Iq.Stanza? query_archive(XmppStream stream, string? jid, string? query_id, DateTime? start_time, string? start_id, DateTime? end_time, string? end_id) { - if (stream.get_flag(Flag.IDENTITY) == null) return null; - - var query_node = crate_base_query(stream, jid, query_id, start_time, end_time); - query_node.put_node(create_set_rsm_node(end_id)); - Iq.Stanza iq = new Iq.Stanza.set(query_node); - - return yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, iq); - } - public override void attach(XmppStream stream) { stream.get_module(MessageModule.IDENTITY).received_pipeline.connect(received_pipeline_listener); stream.stream_negotiated.connect(query_availability); @@ -75,25 +34,6 @@ public class Module : XmppStreamModule { public override string get_ns() { return NS_URI; } public override string get_id() { return IDENTITY.id; } - public async Iq.Stanza? page_through_results(XmppStream stream, string? jid, string? query_id, DateTime? start_time, DateTime? end_time, Iq.Stanza iq) { - - string? complete = iq.stanza.get_deep_attribute("urn:xmpp:mam:2:fin", "complete"); - if (complete == "true") { - return null; - } - string? first = iq.stanza.get_deep_string_content(NS_VER(stream) + ":fin", "http://jabber.org/protocol/rsm" + ":set", "first"); - if (first == null) { - return null; - } - - var query_node = crate_base_query(stream, jid, query_id, start_time, end_time); - query_node.put_node(create_set_rsm_node(first)); - - Iq.Stanza paging_iq = new Iq.Stanza.set(query_node); - - return yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, paging_iq); - } - private async void query_availability(XmppStream stream) { Jid own_jid = stream.get_flag(Bind.Flag.IDENTITY).my_jid.bare_jid; @@ -113,6 +53,52 @@ public class Module : XmppStreamModule { } } + internal StanzaNode create_base_query(XmppStream stream, string ns, string? queryid, Gee.List fields) { + DataForms.DataForm data_form = new DataForms.DataForm(); + + DataForms.DataForm.HiddenField form_type_field = new DataForms.DataForm.HiddenField() { var="FORM_TYPE" }; + form_type_field.set_value_string(NS_VER(stream)); + data_form.add_field(form_type_field); + + foreach (var field in fields) { + data_form.add_field(field); + } + + StanzaNode query_node = new StanzaNode.build("query", NS_VER(stream)).add_self_xmlns().put_node(data_form.get_submit_node()); + if (queryid != null) { + query_node.put_attribute("queryid", queryid); + } + return query_node; + } + + internal async QueryResult query_archive(XmppStream stream, string ns, Jid? mam_server, StanzaNode query_node) { + var res = new QueryResult(); + + if (stream.get_flag(Flag.IDENTITY) == null) { res.error = true; return res; } + + // Build and send query + Iq.Stanza iq = new Iq.Stanza.set(query_node) { to=mam_server }; + + print(@"OUT:\n$(iq.stanza.to_string())\n"); + Iq.Stanza result_iq = yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, iq); + + print(result_iq.stanza.to_string() + "\n"); + + // Parse the response IQ into a QueryResult. + StanzaNode? fin_node = result_iq.stanza.get_subnode("fin", ns); + if (fin_node == null) { print(@"$ns a1\n"); res.malformed = true; return res; } + + StanzaNode? rsm_node = fin_node.get_subnode("set", Xmpp.ResultSetManagement.NS_URI); + if (rsm_node == null) { print("a2\n"); res.malformed = true; return res; } + + res.first = rsm_node.get_deep_string_content("first"); + res.last = rsm_node.get_deep_string_content("last"); + if ((res.first == null) != (res.last == null)) { print("a3\n"); res.malformed = true; } + res.complete = fin_node.get_attribute_bool("complete", false, ns); + + return res; + } + public class ReceivedPipelineListener : StanzaListener { private string[] after_actions_const = {}; @@ -123,19 +109,13 @@ public class ReceivedPipelineListener : StanzaListener { public override async bool run(XmppStream stream, MessageStanza message) { if (stream.get_flag(Flag.IDENTITY) == null) return false; - StanzaNode? message_node = message.stanza.get_deep_subnode(NS_VER(stream) + ":result", "urn:xmpp:forward:0:forwarded", Xmpp.NS_URI + ":message"); + StanzaNode? message_node = message.stanza.get_deep_subnode(NS_VER(stream) + ":result", StanzaForwarding.NS_URI + ":forwarded", Xmpp.NS_URI + ":message"); if (message_node != null) { - // MAM messages must come from our server // TODO or a MUC server - if (!message.from.equals(stream.get_flag(Bind.Flag.IDENTITY).my_jid.bare_jid)) { - warning("Received alleged MAM message from %s, ignoring", message.from.to_string()); - return true; - } - - StanzaNode? forward_node = message.stanza.get_deep_subnode(NS_VER(stream) + ":result", "urn:xmpp:forward:0:forwarded", DelayedDelivery.NS_URI + ":delay"); + StanzaNode? forward_node = message.stanza.get_deep_subnode(NS_VER(stream) + ":result", StanzaForwarding.NS_URI + ":forwarded", DelayedDelivery.NS_URI + ":delay"); DateTime? datetime = DelayedDelivery.get_time_for_node(forward_node); string? mam_id = message.stanza.get_deep_attribute(NS_VER(stream) + ":result", NS_VER(stream) + ":id"); string? query_id = message.stanza.get_deep_attribute(NS_VER(stream) + ":result", NS_VER(stream) + ":queryid"); - message.add_flag(new MessageFlag(datetime, mam_id, query_id)); + message.add_flag(new MessageFlag(message.from, datetime, mam_id, query_id)); message.stanza = message_node; message.rerun_parsing = true; @@ -160,11 +140,13 @@ public class Flag : XmppStreamFlag { public class MessageFlag : Xmpp.MessageFlag { public const string ID = "message_archive_management"; + public Jid sender_jid { get; private set; } public DateTime? server_time { get; private set; } public string? mam_id { get; private set; } public string? query_id { get; private set; } - public MessageFlag(DateTime? server_time, string? mam_id, string? query_id) { + public MessageFlag(Jid sender_jid, DateTime? server_time, string? mam_id, string? query_id) { + this.sender_jid = sender_jid; this.server_time = server_time; this.mam_id = mam_id; this.query_id = query_id; @@ -176,4 +158,8 @@ public class MessageFlag : Xmpp.MessageFlag { public override string get_id() { return ID; } } +private static string NS_VER(XmppStream stream) { + return stream.get_flag(Flag.IDENTITY).ns_ver; } + +} \ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0353_call_invite_message.vala b/xmpp-vala/src/module/xep/0353_call_invite_message.vala index 8031beaf..c467cde7 100644 --- a/xmpp-vala/src/module/xep/0353_call_invite_message.vala +++ b/xmpp-vala/src/module/xep/0353_call_invite_message.vala @@ -68,7 +68,7 @@ namespace Xmpp.Xep.CallInvites { } private void on_received_message(XmppStream stream, MessageStanza message) { - Xep.MessageArchiveManagement.MessageFlag? mam_flag = Xep.MessageArchiveManagement.MessageFlag.get_flag(message); + Xmpp.MessageArchiveManagement.MessageFlag? mam_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message); if (mam_flag != null) return; StanzaNode? relevant_node = null; diff --git a/xmpp-vala/src/module/xep/0353_jingle_message_initiation.vala b/xmpp-vala/src/module/xep/0353_jingle_message_initiation.vala index 730f07e8..2596abbb 100644 --- a/xmpp-vala/src/module/xep/0353_jingle_message_initiation.vala +++ b/xmpp-vala/src/module/xep/0353_jingle_message_initiation.vala @@ -53,7 +53,7 @@ namespace Xmpp.Xep.JingleMessageInitiation { private void on_received_message(XmppStream stream, MessageStanza message) { if (message.type_ == MessageStanza.TYPE_GROUPCHAT) return; - Xep.MessageArchiveManagement.MessageFlag? mam_flag = Xep.MessageArchiveManagement.MessageFlag.get_flag(message); + Xmpp.MessageArchiveManagement.MessageFlag? mam_flag = Xmpp.MessageArchiveManagement.MessageFlag.get_flag(message); if (mam_flag != null) return; StanzaNode? mi_node = null; From 80258a874ddfeb87b4b71f5791eab94a2465de6d Mon Sep 17 00:00:00 2001 From: fiaxh Date: Tue, 11 Oct 2022 13:37:48 +0200 Subject: [PATCH 09/13] Add support for reactions --- libdino/CMakeLists.txt | 1 + libdino/src/application.vala | 1 + libdino/src/plugin/interfaces.vala | 3 +- libdino/src/service/database.vala | 37 +- libdino/src/service/module_manager.vala | 2 + libdino/src/service/muc_manager.vala | 15 + libdino/src/service/reactions.vala | 488 ++++++++++++++++++ main/CMakeLists.txt | 2 + main/data/conversation_content_view/view.ui | 3 + .../actions/dino-emoticon-add-symbolic.svg | 5 + main/data/theme.css | 56 ++ .../conversation_item_skeleton.vala | 10 + .../conversation_view.vala | 87 +++- .../message_widget.vala | 26 +- .../reactions_widget.vala | 192 +++++++ xmpp-vala/CMakeLists.txt | 2 + .../src/module/xep/0421_occupant_ids.vala | 45 ++ xmpp-vala/src/module/xep/0444_reactions.vala | 74 +++ 18 files changed, 1026 insertions(+), 23 deletions(-) create mode 100644 libdino/src/service/reactions.vala create mode 100644 main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg create mode 100644 main/src/ui/conversation_content_view/reactions_widget.vala create mode 100644 xmpp-vala/src/module/xep/0421_occupant_ids.vala create mode 100644 xmpp-vala/src/module/xep/0444_reactions.vala diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 6c120346..99c1426f 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -51,6 +51,7 @@ SOURCES src/service/muc_manager.vala src/service/notification_events.vala src/service/presence_manager.vala + src/service/reactions.vala src/service/registration.vala src/service/roster_manager.vala src/service/search_processor.vala diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 9b36dd79..229a9de1 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -55,6 +55,7 @@ public interface Application : GLib.Application { EntityInfo.start(stream_interactor, db); MessageCorrection.start(stream_interactor, db); FileTransferStorage.start(stream_interactor, db); + Reactions.start(stream_interactor, db); create_actions(); diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index e4710732..b3402457 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -154,7 +154,8 @@ public interface ConversationItemWidgetInterface: Object { public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); public class MessageAction : Object { public string icon_name; - public MessageActionEvoked callback; + public Object? popover; + public MessageActionEvoked? callback; } public abstract class MetaConversationNotification : Object { diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 25a6b477..5f422d2f 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -119,6 +119,20 @@ public class Database : Qlite.Database { } } + public class OccupantIdTable : Table { + public Column id = new Column.Integer("id") { primary_key = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column last_nick = new Column.Text("last_nick"); + public Column jid_id = new Column.Integer("jid_id"); + public Column occupant_id = new Column.Text("occupant_id"); + + internal OccupantIdTable(Database db) { + base(db, "occupant_id"); + init({id, account_id, last_nick, jid_id, occupant_id}); + unique({account_id, jid_id, occupant_id}, "REPLACE"); + } + } + public class UndecryptedTable : Table { public Column message_id = new Column.Integer("message_id"); public Column type_ = new Column.Integer("type"); @@ -277,6 +291,23 @@ public class Database : Qlite.Database { } } + public class ReactionTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column occupant_id = new Column.Integer("occupant_id"); + public Column content_item_id = new Column.Integer("content_item_id") { not_null = true }; + public Column time = new Column.Long("time") { not_null = true }; + public Column jid_id = new Column.Integer("jid_id"); + public Column emojis = new Column.Text("emojis"); + + internal ReactionTable(Database db) { + base(db, "reaction"); + init({id, account_id, occupant_id, content_item_id, time, jid_id, emojis}); + unique({account_id, content_item_id, jid_id}, "REPLACE"); + unique({account_id, content_item_id, occupant_id}, "REPLACE"); + } + } + public class SettingsTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column key = new Column.Text("key") { unique = true, not_null = true }; @@ -308,6 +339,7 @@ public class Database : Qlite.Database { public MessageTable message { get; private set; } public MessageCorrectionTable message_correction { get; private set; } public RealJidTable real_jid { get; private set; } + public OccupantIdTable occupantid { get; private set; } public FileTransferTable file_transfer { get; private set; } public CallTable call { get; private set; } public CallCounterpartTable call_counterpart { get; private set; } @@ -317,6 +349,7 @@ public class Database : Qlite.Database { public EntityFeatureTable entity_feature { get; private set; } public RosterTable roster { get; private set; } public MamCatchupTable mam_catchup { get; private set; } + public ReactionTable reaction { get; private set; } public SettingsTable settings { get; private set; } public ConversationSettingsTable conversation_settings { get; private set; } @@ -332,6 +365,7 @@ public class Database : Qlite.Database { content_item = new ContentItemTable(this); message = new MessageTable(this); message_correction = new MessageCorrectionTable(this); + occupantid = new OccupantIdTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); call = new CallTable(this); @@ -342,9 +376,10 @@ public class Database : Qlite.Database { entity_feature = new EntityFeatureTable(this); roster = new RosterTable(this); mam_catchup = new MamCatchupTable(this); + reaction = new ReactionTable(this); settings = new SettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, message_correction, real_jid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, settings, conversation_settings }); + init({ account, jid, entity, content_item, message, message_correction, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index fc01a687..eeb5369a 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -70,6 +70,7 @@ public class ModuleManager { module_map[account].add(new StreamError.Module()); module_map[account].add(new Xep.InBandRegistration.Module()); module_map[account].add(new Xep.HttpFileUpload.Module()); + module_map[account].add(new Xep.Reactions.Module()); module_map[account].add(new Xep.Socks5Bytestreams.Module()); module_map[account].add(new Xep.InBandBytestreams.Module()); module_map[account].add(new Xep.Jingle.Module()); @@ -80,6 +81,7 @@ public class ModuleManager { module_map[account].add(new Xep.LastMessageCorrection.Module()); module_map[account].add(new Xep.DirectMucInvitations.Module()); module_map[account].add(new Xep.JingleMessageInitiation.Module()); + module_map[account].add(new Xep.OccupantIds.Module()); module_map[account].add(new Xep.JingleRawUdp.Module()); module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.CallInvites.Module()); diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 17787387..4505e992 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -28,6 +28,7 @@ public class MucManager : StreamInteractionModule, Object { private HashMap bookmarks_provider = new HashMap(Account.hash_func, Account.equals_func); private HashMap> invites = new HashMap>(Account.hash_func, Account.equals_func); public HashMap default_muc_server = new HashMap(Account.hash_func, Account.equals_func); + private HashMap> own_occupant_ids = new HashMap>(Account.hash_func, Account.equals_func); public static void start(StreamInteractor stream_interactor) { MucManager m = new MucManager(stream_interactor); @@ -386,6 +387,13 @@ public class MucManager : StreamInteractionModule, Object { return get_own_jid(jid, account) != null; } + public string? get_own_occupant_id(Account account, Jid muc_jid) { + if (account in own_occupant_ids && muc_jid in own_occupant_ids[account]) { + return own_occupant_ids[account][muc_jid]; + } + return null; + } + private void on_account_added(Account account) { stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).self_removed_from_room.connect( (stream, jid, code) => { left(account, jid); @@ -413,6 +421,12 @@ public class MucManager : StreamInteractionModule, Object { private_room_occupant_updated(account, room, occupant); } }); + stream_interactor.module_manager.get_module(account, Xep.OccupantIds.Module.IDENTITY).received_own_occupant_id.connect( (stream, jid, occupant_id) => { + if (!(account in own_occupant_ids)) { + own_occupant_ids[account] = new HashMap(Jid.hash_bare_func, Jid.equals_bare_func); + } + own_occupant_ids[account][jid] = occupant_id; + }); } private async void search_default_muc_server(Account account) { @@ -655,6 +669,7 @@ public class MucManager : StreamInteractionModule, Object { if (m != null) { // For own messages from this device (msg is a duplicate) m.marked = Message.Marked.RECEIVED; + m.server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(stanza, m.counterpart.bare_jid); } // For own messages from other devices (msg is not a duplicate msg) message.marked = Message.Marked.RECEIVED; diff --git a/libdino/src/service/reactions.vala b/libdino/src/service/reactions.vala new file mode 100644 index 00000000..95bb0fa4 --- /dev/null +++ b/libdino/src/service/reactions.vala @@ -0,0 +1,488 @@ +using Gee; +using Qlite; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +public class Dino.Reactions : StreamInteractionModule, Object { + public static ModuleIdentity IDENTITY = new ModuleIdentity("reactions"); + public string id { get { return IDENTITY.id; } } + + public signal void reaction_added(Account account, int content_item_id, Jid jid, string reaction); +// [Signal(detailed=true)] + public signal void reaction_removed(Account account, int content_item_id, Jid jid, string reaction); + + private StreamInteractor stream_interactor; + private Database db; + private HashMap> reaction_infos = new HashMap>(); + + public static void start(StreamInteractor stream_interactor, Database database) { + Reactions m = new Reactions(stream_interactor, database); + stream_interactor.add_module(m); + } + + private Reactions(StreamInteractor stream_interactor, Database database) { + this.stream_interactor = stream_interactor; + this.db = database; + stream_interactor.account_added.connect(on_account_added); + + stream_interactor.get_module(MessageProcessor.IDENTITY).message_sent_or_received.connect(on_new_message); + } + + public void add_reaction(Conversation conversation, ContentItem content_item, string reaction) { + Gee.List reactions = get_own_reactions(conversation, content_item); + if (!reactions.contains(reaction)) { + reactions.add(reaction); + } + send_reactions(conversation, content_item, reactions); + reaction_added(conversation.account, content_item.id, conversation.account.bare_jid, reaction); + } + + public void remove_reaction(Conversation conversation, ContentItem content_item, string reaction) { + Gee.List reactions = get_own_reactions(conversation, content_item); + reactions.remove(reaction); + send_reactions(conversation, content_item, reactions); + reaction_removed(conversation.account, content_item.id, conversation.account.bare_jid, reaction); + } + + public Gee.List get_item_reactions(Conversation conversation, ContentItem content_item) { + if (conversation.type_ == Conversation.Type.CHAT) { + return get_chat_message_reactions(conversation.account, content_item); + } else { + return get_muc_message_reactions(conversation.account, content_item); + } + } + + public async bool conversation_supports_reactions(Conversation conversation) { + if (conversation.type_ == Conversation.Type.CHAT) { + Gee.List? resources = stream_interactor.get_module(PresenceManager.IDENTITY).get_full_jids(conversation.counterpart, conversation.account); + if (resources == null) return false; + + foreach (Jid full_jid in resources) { + bool? has_feature = yield stream_interactor.get_module(EntityInfo.IDENTITY).has_feature(conversation.account, full_jid, Xep.Reactions.NS_URI); + if (has_feature == true) { + return true; + } + } + } else { + // The MUC server needs to 1) support stable stanza ids 2) either support occupant ids or be a private room (where we know real jids) + var entity_info = stream_interactor.get_module(EntityInfo.IDENTITY); + bool server_supports_sid = (yield entity_info.has_feature(conversation.account, conversation.counterpart.bare_jid, Xep.UniqueStableStanzaIDs.NS_URI)) || + (yield entity_info.has_feature(conversation.account, conversation.counterpart.bare_jid, Xmpp.MessageArchiveManagement.NS_URI_2)); + if (!server_supports_sid) return false; + + bool? supports_occupant_ids = yield entity_info.has_feature(conversation.account, conversation.counterpart, Xep.OccupantIds.NS_URI); + if (supports_occupant_ids) return true; + + return stream_interactor.get_module(MucManager.IDENTITY).is_private_room(conversation.account, conversation.counterpart); + } + return false; + } + + private void send_reactions(Conversation conversation, ContentItem content_item, Gee.List reactions) { + Message? message = null; + + FileItem? file_item = content_item as FileItem; + if (file_item != null) { + int message_id = int.parse(file_item.file_transfer.info); + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(message_id, conversation); + } + MessageItem? message_item = content_item as MessageItem; + if (message_item != null) { + message = message_item.message; + } + + if (message == null) { + return; + } + + XmppStream stream = stream_interactor.get_stream(conversation.account); + if (conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) { + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "groupchat", message.server_id ?? message.stanza_id, reactions); + } else if (conversation.type_ == Conversation.Type.GROUPCHAT_PM) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "chat", message.server_id ?? message.stanza_id, reactions); + } + // We save the reaction when it gets reflected back to us + } else if (conversation.type_ == Conversation.Type.CHAT) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "chat", message.stanza_id, reactions); + var datetime_now = new DateTime.now(); + long now_long = (long) (datetime_now.to_unix() * 1000 + datetime_now.get_microsecond()); + save_chat_reactions(conversation.account, conversation.account.bare_jid, content_item.id, now_long, reactions); + } + } + + private Gee.List get_own_reactions(Conversation conversation, ContentItem content_item) { + if (conversation.type_ == Conversation.Type.CHAT) { + return get_chat_user_reactions(conversation.account, content_item.id, conversation.account.bare_jid) + .emojis; + } else if (conversation.type_ == Conversation.Type.GROUPCHAT) { + string own_occupant_id = stream_interactor.get_module(MucManager.IDENTITY).get_own_occupant_id(conversation.account, content_item.jid); + return get_muc_user_reactions(conversation.account, content_item.id, own_occupant_id, conversation.account.bare_jid) + .emojis; + } + return new ArrayList(); + } + + private class ReactionsTime { + public Gee.List? emojis = null; + public long time = -1; + } + + private ReactionsTime get_chat_user_reactions(Account account, int content_item_id, Jid jid) { + int jid_id = db.get_jid_id(jid); + + QueryBuilder query = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item_id) + .with(db.reaction.jid_id, "=", jid_id); + + RowOption row = query.single().row(); + ReactionsTime ret = new ReactionsTime(); + if (row.is_present()) { + ret.emojis = string_to_emoji_list(row[db.reaction.emojis]); + ret.time = row[db.reaction.time]; + } else { + ret.emojis = new ArrayList(); + ret.time = -1; + } + return ret; + } + + private ReactionsTime get_muc_user_reactions(Account account, int content_item_id, string? occupantid, Jid? real_jid) { + QueryBuilder query = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item_id) + .join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id) + .with(db.occupantid.occupant_id, "=", occupantid); + + RowOption row = query.single().row(); + ReactionsTime ret = new ReactionsTime(); + if (row.is_present()) { + ret.emojis = string_to_emoji_list(row[db.reaction.emojis]); + ret.time = row[db.reaction.time]; + } else { + ret.emojis = new ArrayList(); + ret.time = -1; + } + return ret; + } + + private Gee.List string_to_emoji_list(string emoji_str) { + ArrayList ret = new ArrayList(); + foreach (string emoji in emoji_str.split(",")) { + if (emoji.length != 0) + ret.add(emoji); + } + return ret; + } + + public Gee.List get_chat_message_reactions(Account account, ContentItem content_item) { + QueryBuilder select = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item.id) + .order_by(db.reaction.time, "DESC"); + + var ret = new ArrayList(); + var index = new HashMap(); + foreach (Row row in select) { + string emoji_str = row[db.reaction.emojis]; + Jid jid = db.get_jid_by_id(row[db.reaction.jid_id]); + + foreach (string emoji in emoji_str.split(",")) { + if (!index.has_key(emoji)) { + index[emoji] = new ReactionUsers() { reaction=emoji, jids=new ArrayList(Jid.equals_func) }; + ret.add(index[emoji]); + } + index[emoji].jids.add(jid); + } + } + return ret; + } + + public Gee.List get_muc_message_reactions(Account account, ContentItem content_item) { + QueryBuilder select = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item.id) + .join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id) + .order_by(db.reaction.time, "DESC"); + + string? own_occupant_id = stream_interactor.get_module(MucManager.IDENTITY).get_own_occupant_id(account, content_item.jid); + + var ret = new ArrayList(); + var index = new HashMap(); + foreach (Row row in select) { + string emoji_str = row[db.reaction.emojis]; + + Jid jid = null; + if (row[db.occupantid.occupant_id] == own_occupant_id) { + jid = account.bare_jid; + } else { + string nick = row[db.occupantid.last_nick]; + jid = content_item.jid.with_resource(nick); + } + + foreach (string emoji in emoji_str.split(",")) { + if (!index.has_key(emoji)) { + index[emoji] = new ReactionUsers() { reaction=emoji, jids=new ArrayList(Jid.equals_func) }; + ret.add(index[emoji]); + } + index[emoji].jids.add(jid); + } + } + return ret; + } + + private void on_account_added(Account account) { + // TODO get time from delays + stream_interactor.module_manager.get_module(account, Xmpp.Xep.Reactions.Module.IDENTITY).received_reactions.connect((stream, from_jid, message_id, reactions, stanza) => { + on_reaction_received.begin(account, from_jid, message_id, reactions, stanza); + }); + } + + private async void on_reaction_received(Account account, Jid from_jid, string message_id, Gee.List reactions, MessageStanza stanza) { + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + // Apply the same restrictions for incoming reactions as we do on sending them + Conversation muc_conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from_jid, account.bare_jid, account, MessageStanza.TYPE_GROUPCHAT); + bool muc_supports_reactions = yield conversation_supports_reactions(muc_conversation); + if (!muc_supports_reactions) return; + } + + Message reaction_message = yield stream_interactor.get_module(MessageProcessor.IDENTITY).parse_message_stanza(account, stanza); + Conversation conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(reaction_message); + + Message? message = get_message_for_reaction(conversation, message_id); + var reaction_info = new ReactionInfo() { account=account, from_jid=from_jid, reactions=reactions, stanza=stanza, received_time=new DateTime.now() }; + + if (message != null) { + process_reaction_for_message(message.id, reaction_info); + return; + } + + // Store reaction infos for later processing after we got the message + print(@"Got reaction for $message_id but dont have message yet $(db.get_jid_id(stanza.from.bare_jid))\n"); + if (!reaction_infos.has_key(message_id)) { + reaction_infos[message_id] = new ArrayList(); + } + reaction_infos[message_id].add(reaction_info); + } + + private void on_new_message(Message message, Conversation conversation) { + Gee.List? reaction_info_list = null; + if (conversation.type_ == Conversation.Type.CHAT) { + reaction_info_list = reaction_infos[message.stanza_id]; + } else { + reaction_info_list = reaction_infos[message.server_id]; + } + if (reaction_info_list == null) return; + + // Check if the (or potentially which) reaction fits the message + ReactionInfo? reaction_info = null; + foreach (ReactionInfo info in reaction_info_list) { + if (!info.account.equals(conversation.account)) return; + switch (info.stanza.type_) { + case MessageStanza.TYPE_CHAT: + Jid counterpart = message.from.equals_bare(conversation.account.bare_jid) ? info.stanza.from: info.stanza.to; + if (message.type_ != Message.Type.CHAT || !counterpart.equals_bare(conversation.counterpart)) continue; + break; + case MessageStanza.TYPE_GROUPCHAT: + if (message.type_ != Message.Type.GROUPCHAT || !message.from.equals_bare(conversation.counterpart)) continue; + break; + default: + break; + } + + reaction_info = info; + } + if (reaction_info == null) return; + reaction_info_list.remove(reaction_info); + if (reaction_info_list.is_empty) reaction_infos.unset(message.stanza_id); + + print(@"Got message for reaction\n"); + process_reaction_for_message(message.id, reaction_info); + } + + private Message? get_message_for_reaction(Conversation conversation, string message_id) { + // Query message from a specific account and counterpart. This also makes sure it's a valid reaction for the message. + if (conversation.type_ == Conversation.Type.CHAT) { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(message_id, conversation); + } else { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(message_id, conversation); + } + } + + private void process_reaction_for_message(int message_db_id, ReactionInfo reaction_info) { + Account account = reaction_info.account; + MessageStanza stanza = reaction_info.stanza; + Jid from_jid = reaction_info.from_jid; + Gee.List reactions = reaction_info.reactions; + + RowOption file_transfer_row = db.file_transfer.select() + .with(db.file_transfer.account_id, "=", account.id) + .with(db.file_transfer.info, "=", message_db_id.to_string()) + .single().row(); // TODO better + + var content_item_row = db.content_item.select(); + + if (file_transfer_row.is_present()) { + content_item_row.with(db.content_item.foreign_id, "=", file_transfer_row[db.file_transfer.id]) + .with(db.content_item.content_type, "=", 2); + } else { + content_item_row.with(db.content_item.foreign_id, "=", message_db_id) + .with(db.content_item.content_type, "=", 1); + } + var content_item_row_opt = content_item_row.single().row(); + if (!content_item_row_opt.is_present()) return; + int content_item_id = content_item_row_opt[db.content_item.id]; + + // Get reaction time + DateTime? reaction_time = null; + DelayedDelivery.MessageFlag? delayed_message_flag = DelayedDelivery.MessageFlag.get_flag(stanza); + if (delayed_message_flag != null) { + reaction_time = delayed_message_flag.datetime; + } + if (reaction_time == null) { + MessageArchiveManagement.MessageFlag? mam_message_flag = MessageArchiveManagement.MessageFlag.get_flag(stanza); + if (mam_message_flag != null) reaction_time = mam_message_flag.server_time; + } + var time_now = new DateTime.now_local(); + if (reaction_time == null) reaction_time = time_now; + if (reaction_time.compare(time_now) > 0) { + reaction_time = reaction_info.received_time; + } + long reaction_time_long = (long) (reaction_time.to_unix() * 1000 + reaction_time.get_microsecond() / 1000); + + // Get current reactions + string? occupant_id = OccupantIds.get_occupant_id(stanza.stanza); + Jid? real_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(from_jid, account); + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT && occupant_id == null && real_jid == null) { + warning("Attempting to add reaction to message w/o knowing occupant id or real jid"); + return; + } + + ReactionsTime reactions_time = null; + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + reactions_time = get_muc_user_reactions(account, content_item_id, occupant_id, real_jid); + } else if (stanza.type_ == MessageStanza.TYPE_CHAT) { + reactions_time = get_chat_user_reactions(account, content_item_id, from_jid); + } + + if (reaction_time_long <= reactions_time.time) { + // We already have a more recent reaction + return; + } + + // Save reactions + if (stanza.type_ == MessageStanza.TYPE_CHAT) { + save_chat_reactions(account, from_jid, content_item_id, reaction_time_long, reactions); + } else if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + save_muc_reactions(account, content_item_id, from_jid, occupant_id, real_jid, reaction_time_long, reactions); + } + + // Notify about reaction changes + Gee.List? current_reactions = reactions_time.emojis; + + Jid signal_jid = from_jid; + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT && + signal_jid.equals(stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(from_jid, account))) { + signal_jid = account.bare_jid; + } + + foreach (string current_reaction in current_reactions) { + if (!reactions.contains(current_reaction)) { + reaction_removed(account, content_item_id, signal_jid, current_reaction); + } + } + foreach (string new_reaction in reactions) { + if (!current_reactions.contains(new_reaction)) { + reaction_added(account, content_item_id, signal_jid, new_reaction); + } + } + + print("reactions were: "); + foreach (string reac in current_reactions) { + print(reac + " "); + } + print("\n"); + print("reactions new : "); + foreach (string reac in reactions) { + print(reac + " "); + } + print("\n"); + } + + private void save_chat_reactions(Account account, Jid jid, int content_item_id, long reaction_time, Gee.List reactions) { + var emoji_builder = new StringBuilder(); + for (int i = 0; i < reactions.size; i++) { + if (i != 0) emoji_builder.append(","); + emoji_builder.append(reactions[i]); + } + + db.reaction.upsert() + .value(db.reaction.account_id, account.id, true) + .value(db.reaction.content_item_id, content_item_id, true) + .value(db.reaction.jid_id, db.get_jid_id(jid), true) + .value(db.reaction.emojis, emoji_builder.str, false) + .value(db.reaction.time, reaction_time, false) + .perform(); + } + + private void save_muc_reactions(Account account, int content_item_id, Jid jid, string? occupant_id, Jid? real_jid, long reaction_time, Gee.List reactions) { + assert(occupant_id != null || real_jid != null); + + int jid_id = db.get_jid_id(jid); + + var emoji_builder = new StringBuilder(); + for (int i = 0; i < reactions.size; i++) { + if (i != 0) emoji_builder.append(","); + emoji_builder.append(reactions[i]); + } + + var builder = db.reaction.upsert() + .value(db.reaction.account_id, account.id, true) + .value(db.reaction.content_item_id, content_item_id, true) + .value(db.reaction.emojis, emoji_builder.str, false) + .value(db.reaction.time, reaction_time, false); + + if (real_jid != null) { + builder.value(db.reaction.jid_id, db.get_jid_id(real_jid), occupant_id == null); + } + + if (occupant_id != null) { + RowOption row = db.occupantid.select() + .with(db.occupantid.account_id, "=", account.id) + .with(db.occupantid.jid_id, "=", jid_id) + .with(db.occupantid.occupant_id, "=", occupant_id) + .single().row(); + + int occupant_db_id = -1; + if (row.is_present()) { + occupant_db_id = row[db.occupantid.id]; + } else { + occupant_db_id = (int)db.occupantid.upsert() + .value(db.occupantid.account_id, account.id, true) + .value(db.occupantid.jid_id, jid_id, true) + .value(db.occupantid.occupant_id, occupant_id, true) + .value(db.occupantid.last_nick, jid.resourcepart, false) + .perform(); + } + builder.value(db.reaction.occupant_id, occupant_db_id, true); + } + + builder.perform(); + } +} + +public class Dino.ReactionUsers { + public string reaction { get; set; } + public Gee.List jids { get; set; } +} + +public class Dino.ReactionInfo { + public Account account { get; set; } + public Jid from_jid { get; set; } + public Gee.List reactions { get; set; } + public MessageStanza stanza { get; set; } + public DateTime received_time { get; set; } +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f5796651..4fc06339 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -17,6 +17,7 @@ set(RESOURCE_LIST dino-conversation-list-placeholder-arrow.svg icons/scalable/actions/dino-account-plus-symbolic.svg + icons/scalable/actions/dino-emoticon-add-symbolic.svg icons/scalable/actions/dino-emoticon-symbolic.svg icons/scalable/actions/dino-qr-code-symbolic.svg @@ -156,6 +157,7 @@ SOURCES src/ui/conversation_content_view/file_image_widget.vala src/ui/conversation_content_view/file_widget.vala src/ui/conversation_content_view/message_widget.vala + src/ui/conversation_content_view/reactions_widget.vala src/ui/conversation_content_view/subscription_notification.vala src/ui/chat_input/chat_input_controller.vala diff --git a/main/data/conversation_content_view/view.ui b/main/data/conversation_content_view/view.ui index d64c0982..a9aae318 100644 --- a/main/data/conversation_content_view/view.ui +++ b/main/data/conversation_content_view/view.ui @@ -36,6 +36,9 @@ 10 end start + 0 diff --git a/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg b/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg new file mode 100644 index 00000000..51cc75c6 --- /dev/null +++ b/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/main/data/theme.css b/main/data/theme.css index b689d96c..c4bc36c3 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -82,6 +82,8 @@ window.dino-main .dino-sidebar > frame { border-bottom: 1px solid @borders; } +/* Message */ + .message-box { transition: background .05s ease; } @@ -107,6 +109,21 @@ window.dino-main .dino-conversation .message-box.error:hover { background: alpha(@error_color, 0.12); } +/* Message Menu */ + +.message-menu-box { + background-color: @theme_base_color; + border: 1px solid alpha(@theme_fg_color, 0.15); + border-radius: 5px; +} + +.message-menu-button { + padding: 6px; + border: none; +} + +/* Fie Widget */ + window.dino-main .file-box-outer, window.dino-main .call-box-outer { background: @theme_base_color; @@ -140,6 +157,8 @@ window.dino-main .file-image-widget .file-box-outer button:hover { background: rgba(100, 100, 100, 0.5); } +/* Call widget */ + window.dino-main .call-box-outer.incoming { border-color: alpha(@theme_selected_bg_color, 0.3); } @@ -153,6 +172,39 @@ window.dino-main .multiparty-participants { background: alpha(@theme_fg_color, 0.04); } +/* Reactions */ + +window.dino-main button.reaction-box, +window.dino-main menubutton.reaction-box > button { + border: 1px solid transparent; + padding: 3px 5px ; + border-radius: 10px; + background-color: alpha(@theme_fg_color, 0.05); + background-image: none; + box-shadow: none; + min-height: 0; + min-width: 0; +} + +window.dino-main button.reaction-box.own-reaction, +window.dino-main menubutton.reaction-box.own-reaction > button { + color: mix(@theme_selected_bg_color, @theme_fg_color, 0.4); + border-color: @theme_selected_bg_color; + background-color: alpha(@theme_selected_bg_color, 0.05); +} + +window.dino-main button.reaction-box:hover, +window.dino-main menubutton.reaction-box:hover > button { + background-color: alpha(@theme_fg_color, 0.1); +} + +window.dino-main button.reaction-box.own-reaction:hover, +window.dino-main menubutton.reaction-box.own-reaction > button { + background-color: alpha(@theme_selected_bg_color, 0.2); +} + +/* Sidebar */ + window.dino-main .dino-sidebar > frame.collapsed { border-bottom: 1px solid @borders; } @@ -165,6 +217,8 @@ window.dino-main .dino-sidebar frame.auto-complete list > row { transition: none; } +/* File overlay */ + window.dino-main .dino-white-overlay { background: @theme_base_color; } @@ -175,6 +229,8 @@ window.dino-main .dino-file-overlay { box-shadow: 0 2px 3px alpha(black, 0.1); } +/* Chat Input*/ + window.dino-main .dino-chatinput frame box { background: transparent; } diff --git a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala index e4e6b804..21aca876 100644 --- a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala +++ b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala @@ -32,6 +32,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, public Entities.Message.Marked item_mark { get; set; } public ContentMetaItem content_meta_item = null; public Widget? widget = null; + private ReactionsController? reactions_controller = null; private uint time_update_timeout = 0; private ulong updated_roster_handler_id = 0; @@ -64,6 +65,15 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, this.notify["show-skeleton"].connect(update_margin); this.notify["show-skeleton"].connect(set_header); + ContentMetaItem? content_meta_item = item as ContentMetaItem; + if (content_meta_item != null) { + reactions_controller = new ReactionsController(conversation, content_meta_item.content_item, stream_interactor); + reactions_controller.box_activated.connect((widget) => { + main_grid.attach(widget, 1, 2, 4, 1); + }); + reactions_controller.init(); + } + update_margin(); } diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index 4babbdb4..caeee09a 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -15,19 +15,20 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug [GtkChild] public unowned ScrolledWindow scrolled; [GtkChild] private unowned Revealer notification_revealer; [GtkChild] private unowned Box message_menu_box; - [GtkChild] private unowned Button button1; - [GtkChild] private unowned Image button1_icon; [GtkChild] private unowned Box notifications; [GtkChild] private unowned Box main; [GtkChild] private unowned Box main_wrap_box; [GtkChild] private unowned Stack stack; + private ArrayList action_buttons = new ArrayList(); + private Gee.List? message_actions = null; + private StreamInteractor stream_interactor; private Gee.TreeSet content_items = new Gee.TreeSet(compare_meta_items); private Gee.TreeSet meta_items = new TreeSet(compare_meta_items); private Gee.HashMap item_item_skeletons = new Gee.HashMap(); private Gee.HashMap widgets = new Gee.HashMap(); - private Gee.List item_skeletons = new Gee.ArrayList(); + private Gee.List widget_order = new Gee.ArrayList(); private ContentProvider content_populator; private SubscriptionNotitication subscription_notification; @@ -81,11 +82,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug main.add_controller(main_motion_events); main_motion_events.motion.connect(update_highlight); - button1.clicked.connect(() => { - current_meta_item.get_item_actions(Plugins.WidgetType.GTK4)[0].callback(button1, current_meta_item, currently_highlighted); - update_message_menu(); - }); - return this; } @@ -107,7 +103,20 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } } + private bool is_highlight_fixed() { + foreach (Widget widget in action_buttons) { + MenuButton? menu_button = widget as MenuButton; + if (menu_button != null && menu_button.popover.visible) return true; + + ToggleButton? toggle_button = widget as ToggleButton; + if (toggle_button != null && toggle_button.active) return true; + } + return false; + } + private void on_leave_notify_event() { + if (is_highlight_fixed()) return; + if (currently_highlighted != null) { currently_highlighted.remove_css_class("highlight"); currently_highlighted = null; @@ -116,6 +125,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } private void update_highlight(double x, double y) { + if (is_highlight_fixed()) return; + if (currently_highlighted != null && (last_y - y).abs() <= 2) { return; } @@ -174,11 +185,42 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug return; } - var actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK4); - message_menu_box.visible = actions != null && actions.size > 0; - if (actions != null && actions.size == 1) { - button1.visible = true; - button1_icon.set_from_icon_name(actions[0].icon_name); + foreach (Widget widget in action_buttons) { + message_menu_box.remove(widget); + } + action_buttons.clear(); + + message_actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK4); + + if (message_actions != null) { + message_menu_box.visible = true; + + // Configure as many buttons as we need with the actions for the current meta item + for (int i = 0; i < message_actions.size; i++) { + if (message_actions[i].popover != null) { + MenuButton button = new MenuButton(); + button.icon_name = message_actions[i].icon_name; + button.set_popover(message_actions[i].popover as Popover); + action_buttons.add(button); + } + + if (message_actions[i].callback != null) { + var message_action = message_actions[i]; + Button button = new Button(); + button.icon_name = message_action.icon_name; + button.clicked.connect(() => { + print(@"$(current_meta_item.jid) skdfj \n"); + message_action.callback(button, current_meta_item, currently_highlighted); + }); + action_buttons.add(button); + } + } + + foreach (Widget widget in action_buttons) { + message_menu_box.append(widget); + } + } else { + message_menu_box.visible = false; } } @@ -309,7 +351,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug if (skeleton != null) { main.remove(skeleton.get_widget()); widgets.unset(item); - item_skeletons.remove(skeleton); + widget_order.remove(skeleton.get_widget()); item_item_skeletons.unset(item); content_items.remove(item); @@ -353,8 +395,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // Fill datastructure ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item, !animate); item_item_skeletons[item] = item_skeleton; - int index = lower_item != null ? item_skeletons.index_of(item_item_skeletons[lower_item]) + 1 : 0; - item_skeletons.insert(index, item_skeleton); + int index = lower_item != null ? widget_order.index_of(item_item_skeletons[lower_item].get_widget()) + 1 : 0; + widget_order.insert(index, item_skeleton.get_widget()); // Insert widget widgets[item] = item_skeleton.get_widget(); @@ -382,7 +424,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // If an item from the past was added, add everything between that item and the (post-)first present item if (index == 0) { Dino.Application app = Dino.Application.get_default(); - if (item_skeletons.size == 1) { + if (widget_order.size == 1) { foreach (Plugins.ConversationAdditionPopulator populator in app.plugin_registry.conversation_addition_populators) { populator.populate_timespan(conversation, item.time, new DateTime.now_utc()); } @@ -404,6 +446,15 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug (upper_item.mark == Message.Marked.WONTSEND) == (lower_item.mark == Message.Marked.WONTSEND); } + private void on_action_button_clicked(ToggleButton button) { + int button_idx = action_buttons.index_of(button); + print(button_idx.to_string() + "\n"); + Plugins.MessageAction message_action = message_actions[button_idx]; + if (message_action.callback != null) { + message_action.callback(button, current_meta_item, currently_highlighted); + } + } + private void on_upper_notify() { if (was_upper == null || scrolled.vadjustment.value > was_upper - was_page_size - 1) { // scrolled down or content smaller than page size if (at_current_content) { @@ -471,7 +522,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug was_page_size = null; content_items.clear(); meta_items.clear(); - item_skeletons.clear(); + widget_order.clear(); item_item_skeletons.clear(); foreach (Widget widget in widgets.values) { main.remove(widget); diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 3da76226..1f027c89 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -22,6 +22,7 @@ public class MessageMetaItem : ContentMetaItem { MessageItemEditMode? edit_mode = null; ChatTextViewController? controller = null; + private bool supports_reaction = false; AdditionalInfo additional_info = AdditionalInfo.NONE; ulong realize_id = -1; @@ -35,6 +36,8 @@ public class MessageMetaItem : ContentMetaItem { message_item = content_item as MessageItem; this.stream_interactor = stream_interactor; + init.begin(); + label.activate_link.connect(on_label_activate_link); Message message = ((MessageItem) content_item).message; @@ -68,6 +71,10 @@ public class MessageMetaItem : ContentMetaItem { update_label(); } + private async void init() { + supports_reaction = yield stream_interactor.get_module(Reactions.IDENTITY).conversation_supports_reactions(message_item.conversation); + } + private string generate_markup_text(ContentItem item) { MessageItem message_item = item as MessageItem; Conversation conversation = message_item.conversation; @@ -187,11 +194,13 @@ public class MessageMetaItem : ContentMetaItem { } public override Gee.List? get_item_actions(Plugins.WidgetType type) { - if (content_item as FileItem != null) return null; + if (content_item as FileItem != null || this.in_edit_mode) return null; + if (in_edit_mode) return null; - bool allowed = stream_interactor.get_module(MessageCorrection.IDENTITY).is_own_correction_allowed(message_item.conversation, message_item.message); Gee.List actions = new ArrayList(); - if (allowed && !in_edit_mode) { + + bool correction_allowed = stream_interactor.get_module(MessageCorrection.IDENTITY).is_own_correction_allowed(message_item.conversation, message_item.message); + if (correction_allowed) { Plugins.MessageAction action1 = new Plugins.MessageAction(); action1.icon_name = "document-edit-symbolic"; action1.callback = (button, content_meta_item_activated, widget) => { @@ -199,6 +208,17 @@ public class MessageMetaItem : ContentMetaItem { }; actions.add(action1); } + + if (supports_reaction) { + Plugins.MessageAction action2 = new Plugins.MessageAction(); + action2.icon_name = "dino-emoticon-add-symbolic"; + EmojiChooser chooser = new EmojiChooser(); + chooser.emoji_picked.connect((emoji) => { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(message_item.conversation, message_item, emoji); + }); + action2.popover = chooser; + actions.add(action2); + } return actions; } diff --git a/main/src/ui/conversation_content_view/reactions_widget.vala b/main/src/ui/conversation_content_view/reactions_widget.vala new file mode 100644 index 00000000..83b3204e --- /dev/null +++ b/main/src/ui/conversation_content_view/reactions_widget.vala @@ -0,0 +1,192 @@ +using Gee; +using Gtk; + +using Dino.Entities; +using Xmpp; + +namespace Dino.Ui.ConversationSummary { + +public class ReactionsController : Object { + public signal void box_activated(Widget widget); + + private Conversation conversation; + private Account account; + private ContentItem content_item; + private StreamInteractor stream_interactor; + + private HashMap> reactions = new HashMap>(); + + private ReactionsWidget? widget = null; + + public ReactionsController(Conversation conversation, ContentItem content_item, StreamInteractor stream_interactor) { + this.conversation = conversation; + this.account = conversation.account; + this.content_item = content_item; + this.stream_interactor = stream_interactor; + } + + public void init() { + Gee.List reactions = stream_interactor.get_module(Reactions.IDENTITY).get_item_reactions(conversation, content_item); + if (reactions.size > 0) { + initialize_widget(); + } + foreach (ReactionUsers reaction_users in reactions) { + foreach (Jid jid in reaction_users.jids) { + reaction_added(reaction_users.reaction, jid); + } + } + + stream_interactor.get_module(Reactions.IDENTITY).reaction_added.connect((account, content_item_id, jid, reaction) => { + if (this.content_item.id == content_item_id) { + reaction_added(reaction, jid); + } + }); + stream_interactor.get_module(Reactions.IDENTITY).reaction_removed.connect((account, content_item_id, jid, reaction) => { + if (this.content_item.id == content_item_id) { + reaction_removed(reaction, jid); + } + }); + } + + private void initialize_widget() { + widget = new ReactionsWidget() { visible=true }; + widget.emoji_picked.connect((emoji) => { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(conversation, content_item, emoji); + }); + widget.emoji_clicked.connect((emoji) => { + if (account.bare_jid in reactions[emoji]) { + stream_interactor.get_module(Reactions.IDENTITY).remove_reaction(conversation, content_item, emoji); + } else { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(conversation, content_item, emoji); + } + }); + box_activated(widget); + } + + public void reaction_added(string reaction, Jid jid) { + if (widget == null) { + initialize_widget(); + } + + if (!reactions.has_key(reaction)) { + reactions[reaction] = new ArrayList(Jid.equals_func); + } + if (jid.equals_bare(account.bare_jid) && reactions[reaction].contains(jid)) { + return; + } + reactions[reaction].add(jid); + + if (reactions[reaction].size == 0) return; + + widget.update_reaction(reaction, reactions[reaction].size, reactions[reaction].contains(account.bare_jid), update_tooltip(reaction)); + } + + public void reaction_removed(string reaction, Jid jid) { + if (!reactions.has_key(reaction)) return; + reactions[reaction].remove(jid); + + if (reactions[reaction].size > 0) { + widget.update_reaction(reaction, reactions[reaction].size, reactions[reaction].contains(account.bare_jid), update_tooltip(reaction)); + } else { + widget.remove_reaction(reaction); + reactions.unset(reaction); + } + + if (reactions.size == 0) { + widget.unparent(); + widget = null; + } + } + + private Gee.List update_tooltip(string reaction) { + var name_list = new ArrayList(); + if (reactions[reaction].size > 0) { + if (account.bare_jid in reactions[reaction]) { + name_list.add(_("You")); + } + foreach (Jid jid in reactions[reaction]) { + if (jid.equals(account.bare_jid)) continue; + + name_list.add(Util.get_participant_display_name(stream_interactor, conversation, jid)); + } + } + return name_list; + } +} + +public class ReactionsWidget : Grid { + + public signal void emoji_picked(string emoji); + public signal void emoji_clicked(string emoji); + + private HashMap reaction_counts = new HashMap(); + private HashMap reaction_buttons = new HashMap(); + private MenuButton add_button; + + public ReactionsWidget() { + this.row_spacing = this.column_spacing = 5; + this.margin_top = 2; + + add_button = new MenuButton() { tooltip_text= _("Add reaction"), visible=true }; + add_button.get_style_context().add_class("reaction-box"); + Image add_image = new Image.from_icon_name("dino-emoticon-add-symbolic") { margin_start=5, margin_end=5, visible=true }; + add_button.set_child(add_image); + + EmojiChooser chooser = new EmojiChooser(); + chooser.emoji_picked.connect((emoji) => { + emoji_picked(emoji); + }); + add_button.set_popover(chooser); + } + + public void update_reaction(string reaction, int count, bool own, Gee.List names) { + if (!reaction_buttons.has_key(reaction)) { + Label reaction_label = new Label("" + reaction + "") { use_markup=true, visible=true }; + Label count_label = new Label("") { use_markup=true, visible=true }; + Button button = new Button() { visible=true }; + button.get_style_context().add_class("reaction-box"); + Box reaction_box = new Box(Orientation.HORIZONTAL, 4) { visible=true }; + reaction_box.append(reaction_label); + reaction_box.append(count_label); + button.set_child(reaction_box); + + reaction_counts[reaction] = count_label; + reaction_buttons[reaction] = button; + + this.attach(button, (reaction_buttons.size - 1) % 10, (reaction_buttons.size - 1) / 10, 1, 1); + if (add_button.get_parent() != null) this.remove(add_button); + this.attach(add_button, reaction_buttons.size % 10, reaction_buttons.size / 10, 1, 1); + + + button.clicked.connect(() => { + emoji_clicked(reaction); + }); + } + + reaction_counts[reaction].label = "" + count.to_string() + ""; + if (own) { + reaction_buttons[reaction].get_style_context().add_class("own-reaction"); + } else { + reaction_buttons[reaction].get_style_context().remove_class("own-reaction"); + } + + // Build tooltip + StringBuilder tooltip_builder = new StringBuilder (); + for (int i = 0; i < names.size - 1; i++) { + tooltip_builder.append(names[i]); + if (i < names.size - 2) tooltip_builder.append(", "); + } + if (names.size > 1) { + tooltip_builder.append(" and "); + } + tooltip_builder.append(names[names.size - 1]); + tooltip_builder.append(" reacted with " + reaction); + reaction_buttons[reaction].set_tooltip_text(tooltip_builder.str); + } + + public void remove_reaction(string reaction) { + reaction_buttons[reaction].unparent(); + } +} + +} \ No newline at end of file diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 4ad7f0e9..de89f326 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -141,6 +141,8 @@ SOURCES "src/module/xep/0380_explicit_encryption.vala" "src/module/xep/0391_jingle_encrypted_transports.vala" "src/module/xep/0410_muc_self_ping.vala" + "src/module/xep/0421_occupant_ids.vala" + "src/module/xep/0444_reactions.vala" "src/module/xep/pixbuf_storage.vala" "src/util.vala" diff --git a/xmpp-vala/src/module/xep/0421_occupant_ids.vala b/xmpp-vala/src/module/xep/0421_occupant_ids.vala new file mode 100644 index 00000000..ce9f2471 --- /dev/null +++ b/xmpp-vala/src/module/xep/0421_occupant_ids.vala @@ -0,0 +1,45 @@ +namespace Xmpp.Xep.OccupantIds { + +public const string NS_URI = "urn:xmpp:occupant-id:0"; + +public static string? get_occupant_id(StanzaNode stanza) { + StanzaNode? node = stanza.get_subnode("occupant-id", NS_URI); + if (node == null) return null; + + return node.get_attribute("id"); +} + +public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "0421_occupant_ids"); + + public signal void received_occupant_id(XmppStream stream, Jid jid, string occupant_id); + public signal void received_own_occupant_id(XmppStream stream, Jid jid, string occupant_id); + + public override void attach(XmppStream stream) { + stream.get_module(Presence.Module.IDENTITY).received_available.connect(parse_occupant_id_from_presence); + } + + public override void detach(XmppStream stream) { + stream.get_module(Presence.Module.IDENTITY).received_available.disconnect(parse_occupant_id_from_presence); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + + public void parse_occupant_id_from_presence(XmppStream stream, Presence.Stanza presence) { + string? occupant_id = get_occupant_id(presence.stanza); + if (occupant_id == null) return; + + received_occupant_id(stream, presence.from, occupant_id); + + StanzaNode? x_node = presence.stanza.get_subnode("x", "http://jabber.org/protocol/muc#user"); + if (x_node == null) return; + foreach (StanzaNode status_node in x_node.get_subnodes("status", "http://jabber.org/protocol/muc#user")) { + if (int.parse(status_node.get_attribute("code")) == 110) { + received_own_occupant_id(stream, presence.from, occupant_id); + } + } + } +} + +} diff --git a/xmpp-vala/src/module/xep/0444_reactions.vala b/xmpp-vala/src/module/xep/0444_reactions.vala new file mode 100644 index 00000000..90d922d1 --- /dev/null +++ b/xmpp-vala/src/module/xep/0444_reactions.vala @@ -0,0 +1,74 @@ +using Gee; + +namespace Xmpp.Xep.Reactions { + +public const string NS_URI = "urn:xmpp:reactions:0"; + +public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "reactions"); + + public signal void received_reactions(XmppStream stream, Jid from_jid, string message_id, Gee.List reactions, MessageStanza stanza); + + private ReceivedPipelineListener received_pipeline_listener = new ReceivedPipelineListener(); + + public void send_reaction(XmppStream stream, Jid jid, string stanza_type, string message_id, Gee.List reactions) { + StanzaNode reactions_node = new StanzaNode.build("reactions", NS_URI).add_self_xmlns(); + reactions_node.put_attribute("to", message_id); + foreach (string reaction in reactions) { + StanzaNode reaction_node = new StanzaNode.build("reaction", NS_URI); + reaction_node.put_node(new StanzaNode.text(reaction)); + reactions_node.put_node(reaction_node); + } + + MessageStanza message = new MessageStanza() { to=jid, type_=stanza_type }; + message.stanza.put_node(reactions_node); + + MessageProcessingHints.set_message_hint(message, MessageProcessingHints.HINT_STORE); + + stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, message); + } + + public override void attach(XmppStream stream) { + stream.get_module(ServiceDiscovery.Module.IDENTITY).add_feature(stream, NS_URI); + stream.get_module(MessageModule.IDENTITY).received_pipeline.connect(received_pipeline_listener); + } + + public override void detach(XmppStream stream) { + stream.get_module(ServiceDiscovery.Module.IDENTITY).remove_feature(stream, NS_URI); + stream.get_module(MessageModule.IDENTITY).received_pipeline.disconnect(received_pipeline_listener); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } +} + +public class ReceivedPipelineListener : StanzaListener { + + private const string[] after_actions_const = {"EXTRACT_MESSAGE_2"}; + + public override string action_group { get { return ""; } } + public override string[] after_actions { get { return after_actions_const; } } + + public override async bool run(XmppStream stream, MessageStanza message) { + StanzaNode? reactions_node = message.stanza.get_subnode("reactions", NS_URI); + if (reactions_node == null) return false; + + string? to_attribute = reactions_node.get_attribute("to"); + if (to_attribute == null) return false; + + Gee.List reactions = new ArrayList(); + foreach (StanzaNode reaction_node in reactions_node.get_subnodes("reaction", NS_URI)) { + string? reaction = reaction_node.get_string_content(); + if (reaction == null) return false; + + if (!reactions.contains(reaction)) { + reactions.add(reaction); + } + } + stream.get_module(Module.IDENTITY).received_reactions(stream, message.from, to_attribute, reactions, message); + + return false; + } +} + +} From 11b6e615b73e4183a06f9d456634c44ead612336 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Tue, 11 Oct 2022 17:50:54 +0200 Subject: [PATCH 10/13] Don't require use of MenuButton.set_child introduced with GTK 4.6 --- main/src/ui/call_window/call_bottom_bar.vala | 19 ++----------------- .../reactions_widget.vala | 3 +-- main/src/ui/util/helper.vala | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/main/src/ui/call_window/call_bottom_bar.vala b/main/src/ui/call_window/call_bottom_bar.vala index aa318a45..dfa2e4c6 100644 --- a/main/src/ui/call_window/call_bottom_bar.vala +++ b/main/src/ui/call_window/call_bottom_bar.vala @@ -37,7 +37,7 @@ public class Dino.Ui.CallBottomBar : Gtk.Box { audio_button.margin_end = audio_button.margin_bottom = 5; // space for the small settings button audio_button_overlay.set_child(audio_button); audio_button_overlay.add_overlay(audio_settings_button); - menu_button_set_icon_with_size(audio_settings_button, "go-up-symbolic", 10); + Util.menu_button_set_icon_with_size(audio_settings_button, "go-up-symbolic", 10); audio_settings_button.add_css_class("call-mediadevice-settings-button"); main_buttons.append(audio_button_overlay); @@ -47,7 +47,7 @@ public class Dino.Ui.CallBottomBar : Gtk.Box { video_button.margin_end = video_button.margin_bottom = 5; video_button_overlay.set_child(video_button); video_button_overlay.add_overlay(video_settings_button); - menu_button_set_icon_with_size(video_settings_button, "go-up-symbolic", 10); + Util.menu_button_set_icon_with_size(video_settings_button, "go-up-symbolic", 10); video_settings_button.add_css_class("call-mediadevice-settings-button"); main_buttons.append(video_button_overlay); @@ -76,21 +76,6 @@ public class Dino.Ui.CallBottomBar : Gtk.Box { this.add_css_class("call-bottom-bar"); } - private static void menu_button_set_icon_with_size(MenuButton menu_button, string icon_name, int pixel_size) { -#if GTK_4_6 - menu_button.set_child(new Image.from_icon_name(icon_name) { pixel_size=pixel_size }); -#else - menu_button.set_icon_name(icon_name); - var button = menu_button.get_first_child() as Button; - if (button == null) return; - var box = button.child as Box; - if (box == null) return; - var image = box.get_first_child() as Image; - if (image == null) return; - image.pixel_size = pixel_size; -#endif - } - public AudioSettingsPopover? show_audio_device_choices(bool show) { audio_settings_button.visible = show; if (audio_settings_popover != null) audio_settings_popover.visible = false; diff --git a/main/src/ui/conversation_content_view/reactions_widget.vala b/main/src/ui/conversation_content_view/reactions_widget.vala index 83b3204e..6e58b05f 100644 --- a/main/src/ui/conversation_content_view/reactions_widget.vala +++ b/main/src/ui/conversation_content_view/reactions_widget.vala @@ -129,8 +129,7 @@ public class ReactionsWidget : Grid { add_button = new MenuButton() { tooltip_text= _("Add reaction"), visible=true }; add_button.get_style_context().add_class("reaction-box"); - Image add_image = new Image.from_icon_name("dino-emoticon-add-symbolic") { margin_start=5, margin_end=5, visible=true }; - add_button.set_child(add_image); + Util.menu_button_set_icon_with_size(add_button, "dino-emoticon-add-symbolic", 14); EmojiChooser chooser = new EmojiChooser(); chooser.emoji_picked.connect((emoji) => { diff --git a/main/src/ui/util/helper.vala b/main/src/ui/util/helper.vala index df7438b1..0f0fc605 100644 --- a/main/src/ui/util/helper.vala +++ b/main/src/ui/util/helper.vala @@ -452,4 +452,19 @@ public bool use_tooltips() { return Gtk.MINOR_VERSION != 6 || (Gtk.MICRO_VERSION < 4 || Gtk.MICRO_VERSION > 6); } +public static void menu_button_set_icon_with_size(MenuButton menu_button, string icon_name, int pixel_size) { +#if GTK_4_6 + menu_button.set_child(new Image.from_icon_name(icon_name) { pixel_size=pixel_size }); +#else + menu_button.set_icon_name(icon_name); + var button = menu_button.get_first_child() as Button; + if (button == null) return; + var box = button.child as Box; + if (box == null) return; + var image = box.get_first_child() as Image; + if (image == null) return; + image.pixel_size = pixel_size; +#endif +} + } From 09829b33824ab7d1fbf9886b7ed3e42cd8c34ff2 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Tue, 11 Oct 2022 17:57:38 +0200 Subject: [PATCH 11/13] Fix message server_id getting overwritten with null on reconnect --- libdino/src/service/muc_manager.vala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 4505e992..e8390cdf 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -669,7 +669,10 @@ public class MucManager : StreamInteractionModule, Object { if (m != null) { // For own messages from this device (msg is a duplicate) m.marked = Message.Marked.RECEIVED; - m.server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(stanza, m.counterpart.bare_jid); + string? server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(stanza, m.counterpart.bare_jid); + if (server_id != null) { + m.server_id = server_id; + } } // For own messages from other devices (msg is not a duplicate msg) message.marked = Message.Marked.RECEIVED; From a45280f8dfe45f8908b44cd13996316af44117e9 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Tue, 11 Oct 2022 17:56:50 +0200 Subject: [PATCH 12/13] Reactions: Improve style --- main/data/theme.css | 7 ++++++- .../reactions_widget.vala | 14 +++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/main/data/theme.css b/main/data/theme.css index c4bc36c3..d657e0a3 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -174,12 +174,17 @@ window.dino-main .multiparty-participants { /* Reactions */ +window.dino-main menubutton.reaction-box image { + margin-left: 5px; + margin-right: 5px; +} + window.dino-main button.reaction-box, window.dino-main menubutton.reaction-box > button { border: 1px solid transparent; padding: 3px 5px ; border-radius: 10px; - background-color: alpha(@theme_fg_color, 0.05); + background-color: alpha(@theme_fg_color, 0.07); background-image: none; box-shadow: none; min-height: 0; diff --git a/main/src/ui/conversation_content_view/reactions_widget.vala b/main/src/ui/conversation_content_view/reactions_widget.vala index 6e58b05f..c9f93f66 100644 --- a/main/src/ui/conversation_content_view/reactions_widget.vala +++ b/main/src/ui/conversation_content_view/reactions_widget.vala @@ -49,7 +49,7 @@ public class ReactionsController : Object { } private void initialize_widget() { - widget = new ReactionsWidget() { visible=true }; + widget = new ReactionsWidget(); widget.emoji_picked.connect((emoji) => { stream_interactor.get_module(Reactions.IDENTITY).add_reaction(conversation, content_item, emoji); }); @@ -127,7 +127,7 @@ public class ReactionsWidget : Grid { this.row_spacing = this.column_spacing = 5; this.margin_top = 2; - add_button = new MenuButton() { tooltip_text= _("Add reaction"), visible=true }; + add_button = new MenuButton() { tooltip_text= _("Add reaction") }; add_button.get_style_context().add_class("reaction-box"); Util.menu_button_set_icon_with_size(add_button, "dino-emoticon-add-symbolic", 14); @@ -140,11 +140,11 @@ public class ReactionsWidget : Grid { public void update_reaction(string reaction, int count, bool own, Gee.List names) { if (!reaction_buttons.has_key(reaction)) { - Label reaction_label = new Label("" + reaction + "") { use_markup=true, visible=true }; - Label count_label = new Label("") { use_markup=true, visible=true }; - Button button = new Button() { visible=true }; + Label reaction_label = new Label("" + reaction + "") { use_markup=true }; + Label count_label = new Label("") { use_markup=true }; + Button button = new Button(); button.get_style_context().add_class("reaction-box"); - Box reaction_box = new Box(Orientation.HORIZONTAL, 4) { visible=true }; + Box reaction_box = new Box(Orientation.HORIZONTAL, 4); reaction_box.append(reaction_label); reaction_box.append(count_label); button.set_child(reaction_box); @@ -162,7 +162,7 @@ public class ReactionsWidget : Grid { }); } - reaction_counts[reaction].label = "" + count.to_string() + ""; + reaction_counts[reaction].label = "" + count.to_string() + ""; if (own) { reaction_buttons[reaction].get_style_context().add_class("own-reaction"); } else { From d4c674284ed4668fe5e2a323e7302856869707a8 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Wed, 12 Oct 2022 19:23:12 +0200 Subject: [PATCH 13/13] Reactions: Fix xml attribute name --- xmpp-vala/src/module/xep/0444_reactions.vala | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/xmpp-vala/src/module/xep/0444_reactions.vala b/xmpp-vala/src/module/xep/0444_reactions.vala index 90d922d1..3501ca42 100644 --- a/xmpp-vala/src/module/xep/0444_reactions.vala +++ b/xmpp-vala/src/module/xep/0444_reactions.vala @@ -13,7 +13,7 @@ public class Module : XmppStreamModule { public void send_reaction(XmppStream stream, Jid jid, string stanza_type, string message_id, Gee.List reactions) { StanzaNode reactions_node = new StanzaNode.build("reactions", NS_URI).add_self_xmlns(); - reactions_node.put_attribute("to", message_id); + reactions_node.put_attribute("id", message_id); foreach (string reaction in reactions) { StanzaNode reaction_node = new StanzaNode.build("reaction", NS_URI); reaction_node.put_node(new StanzaNode.text(reaction)); @@ -53,8 +53,8 @@ public class ReceivedPipelineListener : StanzaListener { StanzaNode? reactions_node = message.stanza.get_subnode("reactions", NS_URI); if (reactions_node == null) return false; - string? to_attribute = reactions_node.get_attribute("to"); - if (to_attribute == null) return false; + string? id_attribute = reactions_node.get_attribute("id"); + if (id_attribute == null) return false; Gee.List reactions = new ArrayList(); foreach (StanzaNode reaction_node in reactions_node.get_subnodes("reaction", NS_URI)) { @@ -65,7 +65,7 @@ public class ReceivedPipelineListener : StanzaListener { reactions.add(reaction); } } - stream.get_module(Module.IDENTITY).received_reactions(stream, message.from, to_attribute, reactions, message); + stream.get_module(Module.IDENTITY).received_reactions(stream, message.from, id_attribute, reactions, message); return false; }