From 64f09ce50063e6ccc135a535fe98f58751d7ac7d Mon Sep 17 00:00:00 2001 From: Marvin W Date: Sat, 18 Jul 2026 18:10:27 +0200 Subject: Add support for XEP-0490 Message Displayed Synchronization --- dino.doap | 8 + dino.doap.in | 8 + libdino/src/service/chat_interaction.vala | 123 +++++--- libdino/src/service/content_item_store.vala | 5 +- .../service/counterpart_interaction_manager.vala | 83 +++-- libdino/src/service/file_manager.vala | 7 + libdino/src/service/message_processor.vala | 24 ++ libdino/src/service/module_manager.vala | 1 + .../conversation_view.vala | 337 ++++++++++++++++----- .../conversation_selector_row.vala | 3 +- main/src/ui/conversation_view_controller.vala | 10 +- xmpp-vala/meson.build | 1 + xmpp-vala/src/module/xep/0060_pubsub.vala | 11 +- .../0490_message_displayed_synchronization.vala | 92 ++++++ 14 files changed, 546 insertions(+), 167 deletions(-) create mode 100644 xmpp-vala/src/module/xep/0490_message_displayed_synchronization.vala diff --git a/dino.doap b/dino.doap index dee0dd2b..1cde3084 100644 --- a/dino.doap +++ b/dino.doap @@ -801,5 +801,13 @@ 0.1.0 + + + + partial + 1.0.1 + 0.6 + + diff --git a/dino.doap.in b/dino.doap.in index e234e44c..33cf8288 100644 --- a/dino.doap.in +++ b/dino.doap.in @@ -621,5 +621,13 @@ 0.1.0 + + + + partial + 1.0.1 + 0.6 + + diff --git a/libdino/src/service/chat_interaction.vala b/libdino/src/service/chat_interaction.vala index 1254a574..9c8106a0 100644 --- a/libdino/src/service/chat_interaction.vala +++ b/libdino/src/service/chat_interaction.vala @@ -18,6 +18,7 @@ public class ChatInteraction : StreamInteractionModule, Object { private HashMap last_input_interaction = new HashMap(Conversation.hash_func, Conversation.equals_func); private HashMap last_interface_interaction = new HashMap(Conversation.hash_func, Conversation.equals_func); private bool focus_in = false; + private bool scrolled_down = true; public static void start(StreamInteractor stream_interactor) { ChatInteraction m = new ChatInteraction(stream_interactor); @@ -66,6 +67,14 @@ public class ChatInteraction : StreamInteractionModule, Object { on_conversation_unfocused(conversation); } + public void on_scrolled_down_changed(bool scrolled_down, Conversation? conversation) { + if (scrolled_down == this.scrolled_down) return; + this.scrolled_down = scrolled_down; + if (this.scrolled_down && focus_in && selected_conversation == conversation) { + mark_latest_message_displayed(); + } + } + public void on_message_entered(Conversation? conversation) { if (!last_input_interaction.has_key(conversation)) { send_chat_state_notification(conversation, Xep.ChatStateNotifications.STATE_COMPOSING); @@ -84,25 +93,22 @@ public class ChatInteraction : StreamInteractionModule, Object { public void on_conversation_selected(Conversation conversation) { on_conversation_unfocused(selected_conversation); selected_conversation = conversation; + scrolled_down = false; on_conversation_focused(conversation); } private void new_item(ContentItem item, Conversation conversation) { - bool mark_read = is_active_focus(conversation); + bool mark_read = is_active_focus(conversation) && scrolled_down; if (!mark_read) { MessageItem? message_item = item as MessageItem; - if (message_item != null) { - if (message_item.message.direction == Message.DIRECTION_SENT) { - mark_read = true; - } + if (message_item != null && message_item.message.direction == Message.DIRECTION_SENT) { + mark_read = true; } if (message_item == null) { FileItem? file_item = item as FileItem; - if (file_item != null) { - if (file_item.file_transfer.direction == FileTransfer.DIRECTION_SENT) { - mark_read = true; - } + if (file_item != null && file_item.file_transfer.direction == FileTransfer.DIRECTION_SENT) { + mark_read = true; } } } @@ -127,12 +133,8 @@ public class ChatInteraction : StreamInteractionModule, Object { focus_in = true; if (conversation == null) return; focused_in(conversation); - check_send_read(); - ContentItem? latest_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); - if (latest_item != null) { - conversation.read_up_to_item = latest_item.id; - } + if (scrolled_down) mark_latest_message_displayed(); } private void on_conversation_unfocused(Conversation? conversation) { @@ -145,12 +147,15 @@ public class ChatInteraction : StreamInteractionModule, Object { } } - private void check_send_read() { - if (selected_conversation == null) return; - Entities.Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_last_message(selected_conversation); - if (message != null && message.direction == Entities.Message.DIRECTION_RECEIVED) { - send_chat_marker(message, null, selected_conversation, Xep.ChatMarkers.MARKER_DISPLAYED); - } + private async bool mark_latest_message_displayed() { + Conversation conversation = selected_conversation; + if (conversation == null) return false; + Entities.Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_last_message(conversation); + if (message == null || message.direction == Entities.Message.DIRECTION_SENT) return false; + if (message.equals(conversation.read_up_to)) return false; + + bool sent_marker = yield send_and_mark_message_displayed(conversation, message); + return sent_marker; } private bool update_interactions() { @@ -195,43 +200,67 @@ public class ChatInteraction : StreamInteractionModule, Object { // Send chat marker if (message.direction == Entities.Message.DIRECTION_SENT) return false; - if (outer.is_active_focus(conversation)) { - outer.check_send_read(); - outer.send_chat_marker(message, stanza, conversation, Xep.ChatMarkers.MARKER_DISPLAYED); + if (outer.is_active_focus(conversation) && outer.scrolled_down) { + yield outer.send_and_mark_message_displayed(conversation, message); } else { - outer.send_chat_marker(message, stanza, conversation, Xep.ChatMarkers.MARKER_RECEIVED); + outer.send_received_marker(message, stanza, conversation); } return false; } } + public async bool send_and_mark_message_displayed(Conversation conversation, Entities.Message message) { + stream_interactor.get_module(CounterpartInteractionManager.IDENTITY).mark_displayed_up_to_message(conversation, message); + bool sent_marker = send_displayed_marker(message, conversation); + if (!sent_marker) { + sent_marker = yield send_message_displayed_sync(message, conversation); + } + return sent_marker; + } + + private bool send_received_marker(Entities.Message message, Xmpp.MessageStanza? stanza, Conversation conversation) { + if (stanza == null) return false; + if (!Xep.ChatMarkers.Module.requests_marking(stanza)) return false; + if (message.type_ == Message.Type.GROUPCHAT) return false; + if (message.stanza_id == null) return false; + if (message.direction == Entities.Message.DIRECTION_SENT) return false; + if (message.equals(conversation.read_up_to)) return false; + if (conversation.get_send_marker_setting(stream_interactor) != Conversation.Setting.ON) return false; - private void send_chat_marker(Entities.Message message, Xmpp.MessageStanza? stanza, Conversation conversation, string marker) { XmppStream? stream = stream_interactor.get_stream(conversation.account); - if (stream == null) return; + if (stream == null) return false; - switch (marker) { - case Xep.ChatMarkers.MARKER_RECEIVED: - if (stanza != null && Xep.ChatMarkers.Module.requests_marking(stanza) && message.type_ != Message.Type.GROUPCHAT) { - if (message.stanza_id == null) return; - stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from, message.stanza_id, message.get_type_string(), Xep.ChatMarkers.MARKER_RECEIVED); - } - break; - case Xep.ChatMarkers.MARKER_DISPLAYED: - if (conversation.get_send_marker_setting(stream_interactor) == Conversation.Setting.ON) { - if (message.equals(conversation.read_up_to)) return; - conversation.read_up_to = message; - - if (message.type_ == Message.Type.GROUPCHAT || message.type_ == Message.Type.GROUPCHAT_PM) { - if (message.server_id == null) return; - stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from.bare_jid, message.server_id, message.get_type_string(), Xep.ChatMarkers.MARKER_DISPLAYED); - } else { - if (message.stanza_id == null) return; - stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from, message.stanza_id, message.get_type_string(), Xep.ChatMarkers.MARKER_DISPLAYED); - } - } - break; + stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from, message.stanza_id, message.get_type_string(), Xep.ChatMarkers.MARKER_RECEIVED); + return true; + } + + private bool send_displayed_marker(Entities.Message message, Conversation conversation) { + if (message.direction == Entities.Message.DIRECTION_SENT) return false; + if (conversation.get_send_marker_setting(stream_interactor) != Conversation.Setting.ON) return false; + + XmppStream? stream = stream_interactor.get_stream(conversation.account); + if (stream == null) return false; + + if (message.type_.is_muc_semantic() && message.server_id != null) { + stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from.bare_jid, message.server_id, message.get_type_string(), Xep.ChatMarkers.MARKER_DISPLAYED); + return true; + } else if (!message.type_.is_muc_semantic() && message.stanza_id != null) { + stream.get_module(Xep.ChatMarkers.Module.IDENTITY).send_marker(stream, message.from, message.stanza_id, message.get_type_string(), Xep.ChatMarkers.MARKER_DISPLAYED); + return true; } + + return false; + } + + private async bool send_message_displayed_sync(Entities.Message message, Conversation conversation) { + if (message.direction == Entities.Message.DIRECTION_SENT) return false; + if (message.server_id == null) return false; + + XmppStream? stream = stream_interactor.get_stream(conversation.account); + if (stream == null) return false; + + Jid server_id_jid = conversation.type_ == Conversation.Type.GROUPCHAT ? conversation.counterpart : conversation.account.bare_jid; + return yield stream.get_module(Xep.MessageDisplayedSynchronization.Module.IDENTITY).update_message_displayed(stream, conversation.counterpart, server_id_jid, message.server_id); } private void send_delivery_receipt(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { diff --git a/libdino/src/service/content_item_store.vala b/libdino/src/service/content_item_store.vala index 37c82eb7..d74f9b05 100644 --- a/libdino/src/service/content_item_store.vala +++ b/libdino/src/service/content_item_store.vala @@ -76,10 +76,7 @@ public class ContentItemStore : StreamInteractionModule, Object { case 2: FileTransfer? file_transfer = stream_interactor.get_module(FileTransferStorage.IDENTITY).get_file_by_id(foreign_id, conversation); if (file_transfer != null) { - Message? message = null; - if (file_transfer.provider == 0 && file_transfer.info != null) { - message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(int.parse(file_transfer.info), conversation); - } + Message? message = stream_interactor.get_module(FileManager.IDENTITY).get_message_for_file_transfer(file_transfer, conversation); var file_item = new FileItem(file_transfer, conversation, id, message); return file_item; } diff --git a/libdino/src/service/counterpart_interaction_manager.vala b/libdino/src/service/counterpart_interaction_manager.vala index 23db5762..f60cfb20 100644 --- a/libdino/src/service/counterpart_interaction_manager.vala +++ b/libdino/src/service/counterpart_interaction_manager.vala @@ -73,6 +73,9 @@ public class CounterpartInteractionManager : StreamInteractionModule, Object { stream_interactor.module_manager.get_module(account, Xep.ChatStateNotifications.Module.IDENTITY).chat_state_received.connect((stream, jid, state, stanza) => { on_chat_state_received.begin(account, jid, state, stanza); }); + stream_interactor.module_manager.get_module(account, Xep.MessageDisplayedSynchronization.Module.IDENTITY).on_message_displayed.connect((conversation_jid, stanza_id_by_jid, stanza_id) => { + on_message_displayed_received(account, conversation_jid, stanza_id_by_jid, stanza_id); + }); } private void clear_chat_state(Conversation conversation, Jid jid) { @@ -120,17 +123,69 @@ public class CounterpartInteractionManager : StreamInteractionModule, Object { private async void on_chat_marker_received(Account account, Jid jid, string marker, string stanza_id, MessageStanza message_stanza) { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(message_stanza.from, message_stanza.to, account, message_stanza.type_); if (conversation == null) return; - handle_chat_marker(conversation, jid, marker, stanza_id); + handle_chat_marker(conversation, jid, marker, stanza_id, message_stanza); + } + + private void on_message_displayed_received(Account account, Jid conversation_jid, Jid stanza_id_by_jid, string stanza_id) { + Conversation? conversation = null; + if (stanza_id_by_jid.equals(conversation_jid) && conversation_jid.is_bare()) { + conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(conversation_jid, account, Conversation.Type.GROUPCHAT); + } else if (conversation_jid.resourcepart != null && stanza_id_by_jid.equals(account.bare_jid)) { + conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(conversation_jid, account, Conversation.Type.GROUPCHAT_PM); + } else if (stanza_id_by_jid.equals(account.bare_jid)) { + conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(conversation_jid, account, Conversation.Type.CHAT); + } + if (conversation == null) return; + + mark_displayed_up_to_stanza_id(conversation, stanza_id, true); } - private void handle_chat_marker(Conversation conversation, Jid jid, string marker, string stanza_id) { + public bool mark_displayed_up_to_stanza_id(Conversation conversation, string stanza_id, bool server_id_only) { + Entities.Message? message = null; + if (server_id_only) { + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(stanza_id, conversation); + } else if (conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) { + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(stanza_id, conversation); + // Outdated clients might use the message id. Or in MUCs that don't send server ids. + if (message == null) { + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(stanza_id, conversation); + } + } else { + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(stanza_id, conversation); + } + if (message == null) return false; + return mark_displayed_up_to_message(conversation, message); + } + + public bool mark_displayed_up_to_message(Conversation conversation, Message message) { + // Don't move read marker backwards because we get old info from another client + if (conversation.read_up_to != null && conversation.read_up_to.local_time.compare(message.local_time) > 0) return false; + conversation.read_up_to = message; + + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_content_item_for_message(conversation, message); + if (content_item == null) return true; + return mark_displayed_up_to_content_item(conversation, content_item); + } + + public bool mark_displayed_up_to_content_item(Conversation conversation, ContentItem content_item) { + ContentItem? read_up_to_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, conversation.read_up_to_item); + if (read_up_to_item != null && read_up_to_item.compare(content_item) > 0) return false; + conversation.read_up_to_item = content_item.id; + return true; + } + + private void handle_chat_marker(Conversation conversation, Jid jid, string marker, string stanza_id, MessageStanza? message_stanza = null) { // Check if the marker comes from ourselves (own jid or our jid in a MUC) bool own_marker = false; if (conversation.type_ == Conversation.Type.CHAT) { own_marker = conversation.account.bare_jid.to_string() == jid.bare_jid.to_string(); } else { Jid? own_muc_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(jid.bare_jid, conversation.account); + string own_occupant_id = stream_interactor.get_module(MucManager.IDENTITY).get_own_occupant_id(conversation.account, jid.bare_jid); + string occupant_id = Xep.OccupantIds.get_occupant_id(message_stanza.stanza); if (own_muc_jid != null && own_muc_jid.equals(jid)) { + // Don't allow groupchat displayed marker without matching occupant id + if (conversation.type_ == Conversation.Type.GROUPCHAT && (occupant_id == null || own_occupant_id == occupant_id)) return; own_marker = true; } } @@ -138,27 +193,7 @@ public class CounterpartInteractionManager : StreamInteractionModule, Object { if (own_marker) { // If we received a display marker from ourselves (other device), set the conversation read up to that message. if (marker != Xep.ChatMarkers.MARKER_DISPLAYED && marker != Xep.ChatMarkers.MARKER_ACKNOWLEDGED) return; - Entities.Message? message = null; - if (conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) { - message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(stanza_id, conversation); - // Outdated clients might use the message id. Or in MUCs that don't send server ids. - if (message == null) { - message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(stanza_id, conversation); - } - } else { - message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(stanza_id, conversation); - } - if (message == null) return; - // Don't move read marker backwards because we get old info from another client - if (conversation.read_up_to != null && conversation.read_up_to.local_time.compare(message.local_time) > 0) return; - conversation.read_up_to = message; - - // TODO: This only marks messages as read, not http file transfers. - ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_foreign(conversation, 1, message.id); - if (content_item == null) return; - ContentItem? read_up_to_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, conversation.read_up_to_item); - if (read_up_to_item != null && read_up_to_item.compare(content_item) > 0) return; - conversation.read_up_to_item = content_item.id; + mark_displayed_up_to_stanza_id(conversation, stanza_id, false); } else { // We can't currently handle chat markers in MUCs if (conversation.type_ == Conversation.Type.GROUPCHAT) return; @@ -203,7 +238,7 @@ public class CounterpartInteractionManager : StreamInteractionModule, Object { private void on_receipt_received(Account account, Jid jid, string id, MessageStanza stanza) { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(stanza.from, stanza.to, account, stanza.type_); if (conversation == null) return; - handle_chat_marker(conversation, jid,Xep.ChatMarkers.MARKER_RECEIVED, id); + handle_chat_marker(conversation, jid,Xep.ChatMarkers.MARKER_RECEIVED, id, stanza); } } diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala index 8c196dd7..5e59aee1 100644 --- a/libdino/src/service/file_manager.vala +++ b/libdino/src/service/file_manager.vala @@ -420,6 +420,13 @@ public class FileManager : StreamInteractionModule, Object { throw new FileSendError.SAVE_FAILED("Saving file error: %s".printf(e.message)); } } + + public Message? get_message_for_file_transfer(FileTransfer file_transfer, Conversation conversation) { + if ((file_transfer.provider == 0 || file_transfer.provider == 2) && file_transfer.info != null) { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(int.parse(file_transfer.info), conversation); + } + return null; + } } public errordomain FileSendError { diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index 676daa72..7201085e 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -39,6 +39,7 @@ public class MessageProcessor : StreamInteractionModule, Object { received_pipeline.connect(new StoreMessageListener(this, stream_interactor)); received_pipeline.connect(new StoreContentItemListener(stream_interactor)); received_pipeline.connect(new MarkupListener(stream_interactor)); + received_pipeline.connect(new PreDisplayedHandler(stream_interactor)); stream_interactor.account_added.connect(on_account_added); @@ -379,6 +380,29 @@ public class MessageProcessor : StreamInteractionModule, Object { } } + private class PreDisplayedHandler : MessageListener { + public string[] after_actions_const = new string[]{ "STORE_CONTENT_ITEM" }; + public override string action_group { get { return "DISPLAYED_HANDLING"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + + public PreDisplayedHandler(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + var flag = stream_interactor.get_stream(conversation.account).get_flag(MessageDisplayedSynchronization.Flag.IDENTITY); + if (flag != null && message.server_id != null) { + Jid stanza_id_by = message.type_ == Message.Type.GROUPCHAT ? conversation.counterpart.bare_jid : conversation.account.bare_jid; + if (flag.is_displayed(conversation.counterpart, stanza_id_by, message.server_id)) { + stream_interactor.get_module(CounterpartInteractionManager.IDENTITY).mark_displayed_up_to_message(conversation, message); + } + } + return false; + } + } + public Entities.Message create_out_message(string? text, Conversation conversation) { Entities.Message message = new Entities.Message(text); message.type_ = Util.get_message_type_for_conversation(conversation); diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index 127f2d93..f83ce425 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -100,6 +100,7 @@ public class ModuleManager { module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.CallInvites.Module()); module_map[account].add(new Xep.Coin.Module()); + module_map[account].add(new Xep.MessageDisplayedSynchronization.Module()); initialize_account_modules(account, module_map[account]); } } diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index 97e3b01d..e8015698 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -9,6 +9,8 @@ namespace Dino.Ui.ConversationSummary { [GtkTemplate (ui = "/im/dino/Dino/conversation_content_view/view.ui")] public class ConversationView : Widget, Plugins.ConversationItemCollection, Plugins.NotificationCollection { + private const int AROUND_MESSAGE_WINDOW = 40; + private const int EXPAND_MESSAGE_WINDOW = 20; private const int MESSAGE_MENU_BOX_OFFSET = -20; public Conversation? conversation { get; private set; } @@ -32,14 +34,24 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug private ContentProvider content_populator; private SubscriptionNotitication subscription_notification; + private bool scroll_active = false; + private Plugins.MetaConversationItem scroll_target_item; + private bool should_scroll_after_item = false; + private bool should_scroll_with_animation = false; + private bool should_highlight_after_scroll = false; + private bool programmatic_scrolling = false; + + private uint mark_lowest_message_read_timeout = 0; + private double? was_value; private double? was_upper; private double? was_page_size; private Mutex reloading_mutex = Mutex(); private bool firstLoad = true; - private bool at_current_content = true; - private bool reload_messages = true; + public bool at_current_content { + get; private set; default = true; + } Widget currently_highlighted = null; ContentMetaItem? current_meta_item = null; double last_y = -1; @@ -271,7 +283,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } } - public void initialize_for_conversation(Conversation? conversation) { + public void initialize_for_conversation(Conversation? conversation, bool go_to_end = false) { // Workaround for rendering issues if (firstLoad) { main.visible = false; @@ -281,65 +293,142 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug }); firstLoad = false; } - if (conversation == this.conversation && at_current_content) { - // Just make sure we are scrolled down - if (scrolled.vadjustment.value != scrolled.vadjustment.upper) { - scroll_animation(scrolled.vadjustment.upper).play(); + if (conversation == this.conversation) { + if (go_to_end) { + if (at_current_content) { + ensure_scroll_to_end(true); + return; + } + } else { + // Just make sure we are scrolled to unread line + Plugins.MetaConversationItem scroll_target_item = content_items.first_match((it) => it.content_item.id == conversation.read_up_to_item); + if (scroll_target_item != null) { + ensure_scroll_to_item(scroll_target_item, true, true, false); + return; + } } - return; } + start_load_new_conversation(); + Idle.add_once(() => { + initialize_for_conversation_(conversation); + ContentItem? content_item = null; + if (!go_to_end && conversation.read_up_to_item > 0) { + content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, conversation.read_up_to_item); + } + if (content_item != null) { + ContentMetaItem meta_item = populate_messages_for(content_item); + ensure_scroll_to_item(meta_item, true, false, false); + } else { + display_latest(); + at_current_content = true; + // Scroll to end + ensure_scroll_to_end(false); + } + Idle.add_once(finish_load_new_conversation); + }); + } + + private void start_load_new_conversation() { + if (!reloading_mutex.trylock()) { + reloading_mutex.unlock(); + reloading_mutex.lock(); + } + scrolled.vadjustment.freeze_notify(); + main.opacity = 0; clear(); - initialize_for_conversation_(conversation); - display_latest(); - at_current_content = true; - // Scroll to end - scrolled.vadjustment.value = scrolled.vadjustment.upper; } - private void scroll_and_highlight_item(Plugins.MetaConversationItem target, uint duration = 500) { - Widget widget = null; - int h = 0; - foreach (Plugins.MetaConversationItem item in meta_items) { - widget = widgets[item]; - if (target == item) { - break; + private void finish_load_new_conversation() { + programmatic_scrolling = true; + scrolled.vadjustment.thaw_notify(); + programmatic_scrolling = false; + main.opacity = 1; + reloading_mutex.trylock(); + reloading_mutex.unlock(); + start_mark_visible_messages_read(); + } + + private void ensure_scroll_to_item(Plugins.MetaConversationItem target, bool after, bool animate, bool highlight) { + scroll_active = true; + scroll_target_item = target; + should_scroll_after_item = after; + should_highlight_after_scroll = highlight; + should_scroll_with_animation = animate; + apply_scroll(); + } + + private void ensure_scroll_to_end(bool animate) { + scroll_active = true; + scroll_target_item = null; + should_scroll_after_item = true; + should_highlight_after_scroll = false; + should_scroll_with_animation = animate; + apply_scroll(); + } + + private void apply_scroll() { + if (!scroll_active) return; + double target_height = scrolled.vadjustment.value; + Widget? widget = null; + if (scroll_target_item != null) { + widget = widgets[scroll_target_item]; + if (widget == null || !widget.get_realized() || !widget.get_mapped()) { + warning("No widget when trying to scroll, or widget not realized/mapped"); + return; } - h += widget.get_allocated_height(); + float h = 0; + Graphene.Rect bounds; + bool got_bounds = widget.compute_bounds(main, out bounds); + if (!got_bounds) { + warning("No bounds when trying to scroll"); + return; + } + h = bounds.origin.y; + if (should_scroll_after_item) h += bounds.size.height; + target_height = h - scrolled.vadjustment.page_size * 1 / 3; + } else { + target_height = scrolled.vadjustment.upper - scrolled.vadjustment.page_size; } - if (widget != widgets[target]) { - warning("Target item widget not reached"); - return; + programmatic_scrolling = true; + if (!should_scroll_with_animation) { + scrolled.vadjustment.value = target_height; + if (should_highlight_after_scroll && widget != null) { + highlight_once(widget); + } + programmatic_scrolling = false; + } else { + Adw.Animation animation = scroll_animation(target_height, 500); + if (should_highlight_after_scroll && widget != null) { + animation.done.connect(() => { + highlight_once(widget); + }); + } + animation.done.connect(() => { + programmatic_scrolling = false; + should_scroll_with_animation = false; + }); + animation.play(); } - double target_height = h - scrolled.vadjustment.page_size * 1/3; - Adw.Animation animation = scroll_animation(target_height); - animation.done.connect(() => { + } + + private void highlight_once(Widget widget) { + widget.remove_css_class("highlight-once"); + widget.add_css_class("highlight-once"); + Timeout.add(5000, () => { widget.remove_css_class("highlight-once"); - widget.add_css_class("highlight-once"); - Timeout.add(5000, () => { - widget.remove_css_class("highlight-once"); - return false; - }); + should_highlight_after_scroll = false; + return false; }); - animation.play(); } - private Adw.Animation scroll_animation(double target) { - return new Adw.TimedAnimation(scrolled, scrolled.vadjustment.value, target, 500, + private Adw.Animation scroll_animation(double target, uint duration = 500) { + return new Adw.TimedAnimation(scrolled, scrolled.vadjustment.value, target, duration, new Adw.PropertyAnimationTarget(scrolled.vadjustment, "value") ); } - public void initialize_around_message(Conversation conversation, ContentItem content_item) { - if (conversation == this.conversation) { - ContentMetaItem? matching_item = content_items.first_match(it => it.content_item.id == content_item.id); - if (matching_item != null) { - scroll_and_highlight_item(matching_item); - return; - } - } - clear(); - initialize_for_conversation_(conversation); - Gee.List before_items = content_populator.populate_before(conversation, content_item, 40); + private ContentMetaItem populate_messages_for(ContentItem content_item) { + Gee.List before_items = content_populator.populate_before(conversation, content_item, AROUND_MESSAGE_WINDOW); foreach (ContentMetaItem item in before_items) { do_insert_item(item); } @@ -348,20 +437,28 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug content_items.add(meta_item); meta_items.add(meta_item); - Gee.List after_items = content_populator.populate_after(conversation, content_item, 40); + Gee.List after_items = content_populator.populate_after(conversation, content_item, AROUND_MESSAGE_WINDOW); foreach (ContentMetaItem item in after_items) { do_insert_item(item); } - if (after_items.size == 40) { - at_current_content = false; - } + at_current_content = after_items.size < AROUND_MESSAGE_WINDOW; + return meta_item; + } - // Compute where to jump to for centered message, jump, highlight. - reload_messages = false; - Timeout.add(700, () => { - scroll_and_highlight_item(meta_item, 300); - reload_messages = true; - return false; + public void initialize_around_message(Conversation conversation, ContentItem content_item) { + if (conversation == this.conversation) { + ContentMetaItem? matching_item = content_items.first_match(it => it.content_item.id == content_item.id); + if (matching_item != null) { + ensure_scroll_to_item(matching_item, false, true, true); + return; + } + } + start_load_new_conversation(); + Idle.add_once(() => { + initialize_for_conversation_(conversation); + ContentMetaItem meta_item = populate_messages_for(content_item); + ensure_scroll_to_item(meta_item, false, false, true); + Idle.add_once(finish_load_new_conversation); }); } @@ -388,21 +485,18 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug foreach (Plugins.ConversationItemPopulator populator in app.plugin_registry.conversation_addition_populators) { populator.init(conversation, this, Plugins.WidgetType.GTK4); } + foreach (Plugins.NotificationPopulator populator in app.plugin_registry.notification_populators) { + populator.init(conversation, this, Plugins.WidgetType.GTK4); + } content_populator.init(this, conversation, Plugins.WidgetType.GTK4); subscription_notification.init(conversation, this); } private void display_latest() { - Gee.List items = content_populator.populate_latest(conversation, 40); + Gee.List items = content_populator.populate_latest(conversation, AROUND_MESSAGE_WINDOW); foreach (ContentMetaItem item in items) { do_insert_item(item); } - - Application app = GLib.Application.get_default() as Application; - foreach (Plugins.NotificationPopulator populator in app.plugin_registry.notification_populators) { - populator.init(conversation, this, Plugins.WidgetType.GTK4); - } - Idle.add(() => { on_value_notify(); return false; }); } public void insert_item(Plugins.MetaConversationItem item) { @@ -624,16 +718,27 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } 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) { - Idle.add(() => { - // If we do this directly without Idle.add, scrolling down doesn't work properly - scrolled.vadjustment.value = scrolled.vadjustment.upper - scrolled.vadjustment.page_size; // scroll down - return false; - }); + if (scroll_active) { + // Still scrolling, ignore + Idle.add_once(apply_scroll); + return; + } + if (was_upper != null) { + if (scrolled.vadjustment.value > was_upper - was_page_size - 1) { // scrolled down or content smaller than page size + if (at_current_content) { + Idle.add(() => { + // If we do this directly without Idle.add, scrolling down doesn't work properly + programmatic_scrolling = true; + scrolled.vadjustment.value = scrolled.vadjustment.upper - scrolled.vadjustment.page_size; // scroll down + programmatic_scrolling = false; + return false; + }); + } + } else if (scrolled.vadjustment.value < scrolled.vadjustment.upper - scrolled.vadjustment.page_size - 1) { + programmatic_scrolling = true; + scrolled.vadjustment.value = scrolled.vadjustment.upper - was_upper + scrolled.vadjustment.value; // stay at same content + programmatic_scrolling = false; } - } else if (scrolled.vadjustment.value < scrolled.vadjustment.upper - scrolled.vadjustment.page_size - 1) { - scrolled.vadjustment.value = scrolled.vadjustment.upper - was_upper + scrolled.vadjustment.value; // stay at same content } was_upper = scrolled.vadjustment.upper; was_page_size = scrolled.vadjustment.page_size; @@ -642,21 +747,82 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug reloading_mutex.unlock(); } + private void start_mark_visible_messages_read() { + if (mark_lowest_message_read_timeout == 0) { + mark_lowest_message_read_timeout = Timeout.add_once(1000, mark_visible_messages_read); + } + } + + private void mark_visible_messages_read() { + double y; + scrolled.translate_coordinates(main, 0, scrolled.get_allocated_height(), null, out y); + + // Get last item fully displayed + int h = 0; + ContentMetaItem? displayed_item = null; + foreach (Plugins.MetaConversationItem item in meta_items) { + Widget widget = widgets[item]; + h += widget.get_allocated_height() + widget.margin_top + widget.margin_bottom; + if (h >= y) { + break; + } + if (item is ContentMetaItem) { + displayed_item = (ContentMetaItem) item; + } + } + if (displayed_item != null && displayed_item.content_item.id != conversation.read_up_to_item) { + mark_read.begin(displayed_item, (_, res) => { + mark_read.end(res); + }); + } + mark_lowest_message_read_timeout = 0; + } + + private async void mark_read(ContentMetaItem displayed_item) { + if (displayed_item.content_item is MessageItem) { + MessageItem message_item = (MessageItem) displayed_item.content_item; + yield stream_interactor.get_module(ChatInteraction.IDENTITY).send_and_mark_message_displayed(message_item.conversation, message_item.message); + } else if (displayed_item.content_item is FileItem) { + FileItem file_item = (FileItem) displayed_item.content_item; + Message? message = stream_interactor.get_module(FileManager.IDENTITY).get_message_for_file_transfer(file_item.file_transfer, conversation); + if (message != null) { + yield stream_interactor.get_module(ChatInteraction.IDENTITY).send_and_mark_message_displayed(file_item.conversation, message); + } else { + stream_interactor.get_module(CounterpartInteractionManager.IDENTITY).mark_displayed_up_to_content_item(file_item.conversation, file_item); + } + } else { + stream_interactor.get_module(CounterpartInteractionManager.IDENTITY).mark_displayed_up_to_content_item(conversation, displayed_item.content_item); + } + } + private void on_value_notify() { + if (!programmatic_scrolling) { + scroll_active = false; + start_mark_visible_messages_read(); + } if (scrolled.vadjustment.value < 400) { load_earlier_messages(); - } else if (scrolled.vadjustment.upper - (scrolled.vadjustment.value + scrolled.vadjustment.page_size) < 400) { + } else if (scrolled.vadjustment.upper - (scrolled.vadjustment.value + scrolled.vadjustment.page_size) < 400 && !at_current_content) { load_later_messages(); } } + private void stop_scroll_fling() { + Idle.add_once(() => scrolled.vadjustment.value = scrolled.vadjustment.value); + } + private void load_earlier_messages() { was_value = scrolled.vadjustment.value; if (!reloading_mutex.trylock()) return; if (content_items.size > 0) { - Gee.List items = content_populator.populate_before(conversation, ((ContentMetaItem) content_items.first()).content_item, 20); - foreach (ContentMetaItem item in items) { - do_insert_item(item); + Gee.List items = content_populator.populate_before(conversation, ((ContentMetaItem) content_items.first()).content_item, EXPAND_MESSAGE_WINDOW); + if (items.is_empty) { + reloading_mutex.unlock(); + } else { + foreach (ContentMetaItem item in items) { + do_insert_item(item); + } + stop_scroll_fling(); } } else { reloading_mutex.unlock(); @@ -666,12 +832,15 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug private void load_later_messages() { if (!reloading_mutex.trylock()) return; if (content_items.size > 0 && !at_current_content) { - Gee.List items = content_populator.populate_after(conversation, ((ContentMetaItem) content_items.last()).content_item, 20); - if (items.size == 0) { - at_current_content = true; - } - foreach (ContentMetaItem item in items) { - do_insert_item(item); + Gee.List items = content_populator.populate_after(conversation, ((ContentMetaItem) content_items.last()).content_item, EXPAND_MESSAGE_WINDOW); + at_current_content = items.size < EXPAND_MESSAGE_WINDOW; + if (items.is_empty) { + reloading_mutex.unlock(); + } else { + foreach (ContentMetaItem item in items) { + do_insert_item(item); + } + stop_scroll_fling(); } } else { reloading_mutex.unlock(); @@ -690,6 +859,10 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } private void clear() { + if (mark_lowest_message_read_timeout != 0) { + Source.remove(mark_lowest_message_read_timeout); + mark_lowest_message_read_timeout = 0; + } was_upper = null; was_page_size = null; foreach (var item in content_items) { diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index d78038f9..73bc36bd 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -253,9 +253,10 @@ public class ConversationSelectorRow : ListBoxRow { if (update_read_pending) return; update_read_pending = true; Idle.add(() => { + bool force = update_read_pending_force; update_read_pending = false; update_read_pending_force = false; - update_read_idle(update_read_pending_force); + update_read_idle(force); return Source.REMOVE; }, Priority.LOW); } diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index 08f9dd5d..ef13b026 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -56,12 +56,14 @@ public class ConversationViewController : Object { // goto-end floating button var vadjustment = view.conversation_frame.scrolled.vadjustment; vadjustment.notify["value"].connect(() => { - bool button_active = vadjustment.value < vadjustment.upper - vadjustment.page_size; - view.goto_end_revealer.reveal_child = button_active; - view.goto_end_revealer.visible = button_active; + debug_here(); + bool scrolled_up = vadjustment.value < vadjustment.upper - vadjustment.page_size; + view.goto_end_revealer.reveal_child = scrolled_up; + view.goto_end_revealer.visible = scrolled_up; + stream_interactor.get_module(ChatInteraction.IDENTITY).on_scrolled_down_changed(!scrolled_up && view.conversation_frame.at_current_content, conversation); }); view.goto_end_button.clicked.connect(() => { - view.conversation_frame.initialize_for_conversation(conversation); + view.conversation_frame.initialize_for_conversation(conversation, true); }); // Update conversation topic diff --git a/xmpp-vala/meson.build b/xmpp-vala/meson.build index 85bdaadc..3f00ea7b 100644 --- a/xmpp-vala/meson.build +++ b/xmpp-vala/meson.build @@ -136,6 +136,7 @@ sources = files( 'src/module/xep/0461_replies.vala', 'src/module/xep/0463_muc_affiliations_versioning.vala', 'src/module/xep/0482_call_invites.vala', + 'src/module/xep/0490_message_displayed_synchronization.vala', 'src/file_content_type.vala', 'src/util.vala', 'src/weak_map.vala', diff --git a/xmpp-vala/src/module/xep/0060_pubsub.vala b/xmpp-vala/src/module/xep/0060_pubsub.vala index c6395eb9..c9af4263 100644 --- a/xmpp-vala/src/module/xep/0060_pubsub.vala +++ b/xmpp-vala/src/module/xep/0060_pubsub.vala @@ -41,9 +41,9 @@ namespace Xmpp.Xep.Pubsub { retract_listeners.unset(node); } - public async Gee.List? request_all(XmppStream stream, Jid jid, string node) { // TODO multiple nodes gehen auch + public async Gee.List? request_all(XmppStream stream, Jid? jid, string node) { // TODO multiple nodes gehen auch Iq.Stanza request_iq = new Iq.Stanza.get(new StanzaNode.build("pubsub", NS_URI).add_self_xmlns().put_node(new StanzaNode.build("items", NS_URI).put_attribute("node", node))); - request_iq.to = jid; + if (jid != null) request_iq.to = jid; Iq.Stanza iq_res = yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, request_iq); @@ -56,9 +56,9 @@ namespace Xmpp.Xep.Pubsub { } public delegate void OnResult(XmppStream stream, Jid jid, string? id, StanzaNode? node); - public void request(XmppStream stream, Jid jid, string node, owned OnResult listener) { // TODO multiple nodes gehen auch + public void request(XmppStream stream, Jid? jid, string node, owned OnResult listener) { // TODO multiple nodes gehen auch Iq.Stanza request_iq = new Iq.Stanza.get(new StanzaNode.build("pubsub", NS_URI).add_self_xmlns().put_node(new StanzaNode.build("items", NS_URI).put_attribute("node", node))); - request_iq.to = jid; + if (jid != null) request_iq.to = jid; stream.get_module(Iq.Module.IDENTITY).send_iq(stream, request_iq, (stream, iq) => { StanzaNode event_node = iq.stanza.get_subnode("pubsub", NS_URI); StanzaNode items_node = event_node != null ? event_node.get_subnode("items", NS_URI) : null; @@ -100,6 +100,7 @@ namespace Xmpp.Xep.Pubsub { } Iq.Stanza iq = new Iq.Stanza.set(pubsub_node); + if (jid != null) iq.to = jid; // If the node was configured differently before, reconfigure it to meet our requirements and try again Iq.Stanza iq_result = yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, iq); @@ -222,7 +223,7 @@ namespace Xmpp.Xep.Pubsub { } private async bool change_node_config(XmppStream stream, Jid jid, string node, PublishOptions publish_options) { - DataForms.DataForm? data_form = yield stream.get_module(Pubsub.Module.IDENTITY).request_node_config(stream, null, node); + DataForms.DataForm? data_form = yield stream.get_module(Pubsub.Module.IDENTITY).request_node_config(stream, jid, node); if (data_form == null) return false; foreach (DataForms.DataForm.Field field in data_form.fields) { diff --git a/xmpp-vala/src/module/xep/0490_message_displayed_synchronization.vala b/xmpp-vala/src/module/xep/0490_message_displayed_synchronization.vala new file mode 100644 index 00000000..38437735 --- /dev/null +++ b/xmpp-vala/src/module/xep/0490_message_displayed_synchronization.vala @@ -0,0 +1,92 @@ +using Gee; +namespace Xmpp.Xep.MessageDisplayedSynchronization { + public const string NS_URI = "urn:xmpp:mds:displayed:0"; + public const string NS_URI_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0"; + + public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "0490_message_displayed_synchronization"); + + public signal void on_message_displayed(Jid conversation_jid, Jid stanza_id_by_jid, string stanza_id); + + private void handle_displayed_node_item(XmppStream stream, string? id, StanzaNode? item) { + if (id == null || item == null) return; + Jid conversation_jid = Jid.from_string(id); + StanzaNode? stanza_id_node = item.get_subnode("stanza-id", UniqueStableStanzaIDs.NS_URI); + if (stanza_id_node == null || stanza_id_node.get_attribute("by") == null || stanza_id_node.get_attribute("id") == null) return; + Jid stanza_id_by_jid = Jid.from_string(stanza_id_node.get_attribute("by")); + string stanza_id = stanza_id_node.get_attribute("id"); + if (stream.has_flag(Flag.IDENTITY)) { + stream.get_flag(Flag.IDENTITY).learn_mds(conversation_jid, stanza_id_by_jid, stanza_id); + } + on_message_displayed(conversation_jid, stanza_id_by_jid, stanza_id); + } + + private void on_pubsub_item(XmppStream stream, Jid jid, string id, StanzaNode? item) { + if (!jid.equals(stream.get_flag(Bind.Flag.IDENTITY).my_jid.bare_jid)) { + warning("Received alleged mds item from %s, ignoring", jid.to_string()); + return; + } + handle_displayed_node_item(stream, id, item); + } + + private void on_initial_presence_sent(XmppStream stream) { + var pubsub = stream.get_module(Pubsub.Module.IDENTITY); + pubsub.request_all.begin(stream, null, NS_URI, (_, res) => { + Gee.List? items = pubsub.request_all.end(res); + foreach (StanzaNode node_item in items) { + handle_displayed_node_item(stream, node_item.get_attribute("id"), node_item.get_subnode("displayed", NS_URI)); + } + }); + } + + public async bool update_message_displayed(XmppStream stream, Jid conversation_jid, Jid stanza_id_by_jid, string stanza_id) { + var pubsub = stream.get_module(Pubsub.Module.IDENTITY); + var node = new StanzaNode.build("displayed", NS_URI).add_self_xmlns() + .put_node(new StanzaNode.build("stanza-id", UniqueStableStanzaIDs.NS_URI).add_self_xmlns() + .put_attribute("by", stanza_id_by_jid.to_string()) + .put_attribute("id", stanza_id)); + var options = new Pubsub.PublishOptions() + .set_persist_items(true) + .set_max_items("max") + .set_send_last_published_item("never") + .set_access_model("whitelist"); + return yield pubsub.publish(stream, stream.get_flag(Bind.Flag.IDENTITY).my_jid.bare_jid, NS_URI, conversation_jid.to_string(), node, options); + } + + public override void attach(XmppStream stream) { + stream.get_module(Pubsub.Module.IDENTITY).add_filtered_notification(stream, NS_URI, on_pubsub_item, null, null); + stream.get_module(Presence.Module.IDENTITY).initial_presence_sent.connect(on_initial_presence_sent); + if (!stream.has_flag(Flag.IDENTITY)) { + stream.add_flag(new Flag()); + } + } + + public override void detach(XmppStream stream) { + stream.get_module(Presence.Module.IDENTITY).initial_presence_sent.disconnect(on_initial_presence_sent); + stream.get_module(Pubsub.Module.IDENTITY).remove_filtered_notification(stream, NS_URI); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + + } + + public class Flag : XmppStreamFlag { + public static FlagIdentity IDENTITY = new FlagIdentity(NS_URI, "mds"); + + private Map stanza_id_bys = new HashMap(Jid.hash_func, Jid.equals_func); + private Map stanza_ids = new HashMap(Jid.hash_func, Jid.equals_func); + + public void learn_mds(Jid jid, Jid stanza_by, string stanza_id) { + stanza_id_bys[jid] = stanza_by; + stanza_ids[jid] = stanza_id; + } + + public bool is_displayed(Jid conversation_jid, Jid stanza_by, string stanza_id) { + return stanza_id_bys[conversation_jid].equals(stanza_by) && stanza_ids[conversation_jid] == stanza_id; + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + } +} \ No newline at end of file -- cgit v1.3