diff --git a/README.md b/README.md index 8d1bf18c..c253c37e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Contribute License ------- Dino - Modern Jabber/XMPP Client using GTK+/Vala - Copyright (C) 2016-2022 Dino contributors + Copyright (C) 2016-2023 Dino contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/libdino/src/application.vala b/libdino/src/application.vala index ce9ec14a..490cd40c 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -63,10 +63,7 @@ public interface Application : GLib.Application { startup.connect(() => { stream_interactor.connection_manager.log_options = print_xmpp; - Idle.add(() => { - restore(); - return false; - }); + restore(); }); shutdown.connect(() => { stream_interactor.connection_manager.make_offline_all(); diff --git a/libdino/src/entity/account.vala b/libdino/src/entity/account.vala index 3eb75505..2b7f2b04 100644 --- a/libdino/src/entity/account.vala +++ b/libdino/src/entity/account.vala @@ -13,7 +13,7 @@ public class Account : Object { public Jid full_jid { get; private set; } public string? password { get; set; } public string display_name { - owned get { return alias ?? bare_jid.to_string(); } + owned get { return (alias != null && alias.length > 0) ? alias.dup() : bare_jid.to_string(); } } public string? alias { get; set; } public bool enabled { get; set; default = false; } diff --git a/libdino/src/entity/settings.vala b/libdino/src/entity/settings.vala index 97ea5482..0b09e9b9 100644 --- a/libdino/src/entity/settings.vala +++ b/libdino/src/entity/settings.vala @@ -67,6 +67,7 @@ public class Settings : Object { } } + // There is currently no spell checking for GTK4, thus there is currently no UI for this setting. private bool check_spelling_; public bool check_spelling { get { return check_spelling_; } diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index 0fef0134..a73cb5f7 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -153,6 +153,7 @@ public interface ConversationItemWidgetInterface: Object { public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); public class MessageAction : Object { + public bool sensitive = true; public string icon_name; public string? tooltip; public Object? popover; diff --git a/libdino/src/service/entity_info.vala b/libdino/src/service/entity_info.vala index d7d0bc9e..d1217e81 100644 --- a/libdino/src/service/entity_info.vala +++ b/libdino/src/service/entity_info.vala @@ -79,22 +79,34 @@ public class EntityInfo : StreamInteractionModule, Object { } public async bool has_feature(Account account, Jid jid, string feature) { + int has_feature_cached = has_feature_cached_int(account, jid, feature); + if (has_feature_cached != -1) { + return has_feature_cached == 1; + } + + ServiceDiscovery.InfoResult? info_result = yield get_info_result(account, jid, entity_caps_hashes[jid]); + if (info_result == null) return false; + + return info_result.features.contains(feature); + } + + public bool has_feature_cached(Account account, Jid jid, string feature) { + return has_feature_cached_int(account, jid, feature) == 1; + } + + private int has_feature_cached_int(Account account, Jid jid, string feature) { if (jid_features.has_key(jid)) { - return jid_features[jid].contains(feature); + return jid_features[jid].contains(feature) ? 1 : 0; } string? hash = entity_caps_hashes[jid]; if (hash != null) { Gee.List? features = get_stored_features(hash); if (features != null) { - return features.contains(feature); + return features.contains(feature) ? 1 : 0; } } - - ServiceDiscovery.InfoResult? info_result = yield get_info_result(account, jid, hash); - if (info_result == null) return false; - - return info_result.features.contains(feature); + return -1; } private void on_received_available_presence(Account account, Presence.Stanza presence) { diff --git a/libdino/src/service/fallback_body.vala b/libdino/src/service/fallback_body.vala index cc9ba9a6..13323427 100644 --- a/libdino/src/service/fallback_body.vala +++ b/libdino/src/service/fallback_body.vala @@ -64,4 +64,20 @@ public class Dino.FallbackBody : StreamInteractionModule, Object { return false; } } + + public static string get_quoted_fallback_body(ContentItem content_item) { + string fallback = "> "; + + if (content_item.type_ == MessageItem.TYPE) { + Message? quoted_message = ((MessageItem) content_item).message; + fallback += Dino.message_body_without_reply_fallback(quoted_message); + fallback = fallback.replace("\n", "\n> "); + } else if (content_item.type_ == FileItem.TYPE) { + FileTransfer? quoted_file = ((FileItem) content_item).file_transfer; + fallback += quoted_file.file_name; + } + fallback += "\n"; + + return fallback; + } } \ No newline at end of file diff --git a/libdino/src/service/history_sync.vala b/libdino/src/service/history_sync.vala index c7bfee88..e83b3cb4 100644 --- a/libdino/src/service/history_sync.vala +++ b/libdino/src/service/history_sync.vala @@ -11,6 +11,8 @@ public class Dino.HistorySync { private Database db; public HashMap> current_catchup_id = new HashMap>(Account.hash_func, Account.equals_func); + public WeakMap sync_streams = new WeakMap(Account.hash_func, Account.equals_func); + public HashMap> cancellables = new HashMap>(Account.hash_func, Account.equals_func); public HashMap> mam_times = new HashMap>(); public HashMap hitted_range = new HashMap(); @@ -27,9 +29,11 @@ public class Dino.HistorySync { 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); + stream_interactor.stream_negotiated.connect((account, stream) => { + if (current_catchup_id.has_key(account)) { + debug("MAM: [%s] Reset catchup_id", account.bare_jid.to_string()); + current_catchup_id[account].clear(); + } }); } @@ -118,7 +122,7 @@ public class Dino.HistorySync { } } - public async void fetch_everything(Account account, Jid mam_server, DateTime until_earliest_time = new DateTime.from_unix_utc(0)) { + public async void fetch_everything(Account account, Jid mam_server, Cancellable? cancellable = null, DateTime until_earliest_time = new DateTime.from_unix_utc(0)) { debug("Fetch everything for %s %s", mam_server.to_string(), until_earliest_time != null ? @"(until $until_earliest_time)" : ""); RowOption latest_row_opt = db.mam_catchup.select() .with(db.mam_catchup.account_id, "=", account.id) @@ -128,7 +132,7 @@ public class Dino.HistorySync { .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); + Row? new_row = yield fetch_latest_page(account, mam_server, latest_row, until_earliest_time, cancellable); if (new_row != null) { current_catchup_id[account][mam_server] = new_row[db.mam_catchup.id]; @@ -182,7 +186,7 @@ public class Dino.HistorySync { } // 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) { + public async Row? fetch_latest_page(Account account, Jid mam_server, Row? latest_row, DateTime? until_earliest_time, Cancellable? cancellable = null) { debug("[%s | %s] Fetching latest page", account.bare_jid.to_string(), mam_server.to_string()); int latest_row_id = -1; @@ -203,10 +207,10 @@ public class Dino.HistorySync { 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); + PageRequestResult page_result = yield get_mam_page(account, query_params, null, cancellable); debug("[%s | %s] Latest page result: %s", account.bare_jid.to_string(), mam_server.to_string(), page_result.page_result.to_string()); - if (page_result.page_result == PageResult.Error) { + if (page_result.page_result == PageResult.Error || page_result.page_result == PageResult.Cancelled) { return null; } @@ -239,7 +243,7 @@ public class Dino.HistorySync { } // Either we need to fetch more pages or this is the first db entry ever - debug("[%s | %s] Creating new db range for latest page", mam_server.to_string(), mam_server.to_string()); + debug("[%s | %s] Creating new db range for latest page", account.bare_jid.to_string(), mam_server.to_string()); string from_id = page_result.query_result.first; string to_id = page_result.query_result.last; @@ -299,7 +303,7 @@ public class Dino.HistorySync { return null; } - private async void fetch_before_range(Account account, Jid mam_server, Row range, DateTime? until_earliest_time) { + private async void fetch_before_range(Account account, Jid mam_server, Row range, DateTime? until_earliest_time, Cancellable? cancellable = null) { DateTime latest_time = new DateTime.from_unix_utc(range[db.mam_catchup.from_time]); string latest_id = range[db.mam_catchup.from_id]; debug("[%s | %s] Fetching before range < %s, %s", account.bare_jid.to_string(), mam_server.to_string(), latest_time.to_string(), latest_id); @@ -314,21 +318,21 @@ public class Dino.HistorySync { latest_time, latest_id ); } - yield fetch_query(account, query_params, range[db.mam_catchup.id]); + yield fetch_query(account, query_params, range[db.mam_catchup.id], cancellable); } /** * 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) { + private async PageRequestResult fetch_query(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, int db_id, Cancellable? cancellable = null) { debug("[%s | %s] Fetch query %s - %s", account.bare_jid.to_string(), query_params.mam_server.to_string(), query_params.start != null ? query_params.start.to_string() : "", query_params.end != null ? query_params.end.to_string() : ""); PageRequestResult? page_result = null; do { - page_result = yield get_mam_page(account, query_params, page_result); + page_result = yield get_mam_page(account, query_params, page_result, cancellable); debug("Page result %s %b", page_result.page_result.to_string(), page_result.stanzas == null); - if (page_result.page_result == PageResult.Error || page_result.stanzas == null) return page_result; + if (page_result.page_result == PageResult.Error || page_result.page_result == PageResult.Cancelled || page_result.stanzas == null) return page_result; string earliest_mam_id = page_result.query_result.first; long earliest_mam_time = (long)mam_times[account][earliest_mam_id].to_unix(); @@ -354,24 +358,25 @@ public class Dino.HistorySync { TargetReached, NoMoreMessages, Duplicate, - Error + Error, + Cancelled } /** * 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) { + private async PageRequestResult get_mam_page(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, PageRequestResult? prev_page_result, Cancellable? cancellable = null) { 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); + query_result = yield Xmpp.MessageArchiveManagement.V2.query_archive(stream, query_params, cancellable); } else { - query_result = yield Xmpp.MessageArchiveManagement.V2.page_through_results(stream, query_params, prev_page_result.query_result); + query_result = yield Xmpp.MessageArchiveManagement.V2.page_through_results(stream, query_params, prev_page_result.query_result, cancellable); } - return yield process_query_result(account, query_params, query_result); + return yield process_query_result(account, query_params, query_result, cancellable); } - private async PageRequestResult process_query_result(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, Xmpp.MessageArchiveManagement.QueryResult query_result) { + private async PageRequestResult process_query_result(Account account, Xmpp.MessageArchiveManagement.V2.MamQueryParams query_params, Xmpp.MessageArchiveManagement.QueryResult query_result, Cancellable? cancellable = null) { PageResult page_result = PageResult.MorePagesAvailable; if (query_result.malformed || query_result.error) { @@ -394,6 +399,10 @@ public class Dino.HistorySync { string query_id = query_params.query_id; string? after_id = query_params.start_id; + if (cancellable != null && cancellable.is_cancelled()) { + return new PageRequestResult(PageResult.Cancelled, query_result, stanzas[query_id]); + } + if (stanzas.has_key(query_id) && !stanzas[query_id].is_empty) { // Check it we reached our target (from_id) @@ -402,17 +411,21 @@ public class Dino.HistorySync { 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 - var ret = new PageRequestResult(PageResult.TargetReached, query_result, stanzas[query_id]); - send_messages_back_into_pipeline(account, query_id); - return ret; + yield send_messages_back_into_pipeline(account, query_id, cancellable); + if (cancellable != null && cancellable.is_cancelled()) { + return new PageRequestResult(PageResult.Cancelled, query_result, stanzas[query_id]); + } + return new PageRequestResult(PageResult.TargetReached, query_result, stanzas[query_id]); } } } if (hitted_range.has_key(query_id) && hitted_range[query_id] == -2) { - // Message got filtered out by xmpp-vala, but succesfull range fetch nevertheless - var ret = new PageRequestResult(PageResult.TargetReached, query_result, stanzas[query_id]); - send_messages_back_into_pipeline(account, query_id); - return ret; + // Message got filtered out by xmpp-vala, but succesful range fetch nevertheless + yield send_messages_back_into_pipeline(account, query_id); + if (cancellable != null && cancellable.is_cancelled()) { + return new PageRequestResult(PageResult.Cancelled, query_result, stanzas[query_id]); + } + return new PageRequestResult(PageResult.TargetReached, query_result, stanzas[query_id]); } // Check for duplicates. Go through all messages and build a db query. @@ -444,16 +457,19 @@ public class Dino.HistorySync { } } - var res = new PageRequestResult(page_result, query_result, stanzas.has_key(query_id) ? stanzas[query_id] : null); - send_messages_back_into_pipeline(account, query_id); - return res; + yield send_messages_back_into_pipeline(account, query_id); + if (cancellable != null && cancellable.is_cancelled()) { + page_result = PageResult.Cancelled; + } + return new PageRequestResult(page_result, query_result, stanzas.has_key(query_id) ? stanzas[query_id] : null); } - private void send_messages_back_into_pipeline(Account account, string query_id) { + private async void send_messages_back_into_pipeline(Account account, string query_id, Cancellable? cancellable = null) { 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); + if (cancellable != null && cancellable.is_cancelled()) break; + yield stream_interactor.get_module(MessageProcessor.IDENTITY).run_pipeline_announce(account, message); } stanzas.unset(query_id); } @@ -463,14 +479,16 @@ public class Dino.HistorySync { 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; + stream_interactor.connection_manager.stream_attached_modules.connect((account, stream) => { + if (!current_catchup_id.has_key(account)) { + current_catchup_id[account] = new HashMap(Jid.hash_func, Jid.equals_func); + } else { + current_catchup_id[account].clear(); + } + }); - current_catchup_id[account] = new HashMap(Jid.hash_func, Jid.equals_func); - stream_bak = stream; - debug("[%s] MAM available", account.bare_jid.to_string()); - fetch_everything.begin(account, account.bare_jid); + stream_interactor.module_manager.get_module(account, Xmpp.MessageArchiveManagement.Module.IDENTITY).feature_available.connect((stream) => { + consider_fetch_everything(account, stream); }); stream_interactor.module_manager.get_module(account, Xmpp.MessageModule.IDENTITY).received_message_unprocessed.connect((stream, message) => { @@ -478,6 +496,24 @@ public class Dino.HistorySync { }); } + private void consider_fetch_everything(Account account, XmppStream stream) { + if (sync_streams.has(account, stream)) return; + + debug("[%s] MAM available", account.bare_jid.to_string()); + sync_streams[account] = stream; + if (!cancellables.has_key(account)) { + cancellables[account] = new HashMap(); + } + if (cancellables[account].has_key(account.bare_jid)) { + cancellables[account][account.bare_jid].cancel(); + } + cancellables[account][account.bare_jid] = new Cancellable(); + fetch_everything.begin(account, account.bare_jid, cancellables[account][account.bare_jid], new DateTime.from_unix_utc(0), (_, res) => { + fetch_everything.end(res); + cancellables[account].unset(account.bare_jid); + }); + } + 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)) { diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala index 2c9078ea..8f9770d8 100644 --- a/libdino/src/service/message_correction.vala +++ b/libdino/src/service/message_correction.vala @@ -97,9 +97,10 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { if (conversation.type_ != Conversation.Type.CHAT) { - // Don't process messages or corrections from MUC history + // Don't process messages or corrections from MUC history or MUC MAM DateTime? mam_delay = Xep.DelayedDelivery.get_time_for_message(stanza, message.from.bare_jid); if (mam_delay != null) return false; + if (Xmpp.MessageArchiveManagement.MessageFlag.get_flag(stanza) != null) return false; } string? replace_id = Xep.LastMessageCorrection.get_replace_id(stanza); diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index 770ae0a6..12bbeeac 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -34,9 +34,9 @@ public class MessageProcessor : StreamInteractionModule, Object { this.db = db; this.history_sync = new HistorySync(db, stream_interactor); - received_pipeline.connect(new DeduplicateMessageListener(this, db)); + received_pipeline.connect(new DeduplicateMessageListener(this)); received_pipeline.connect(new FilterMessageListener()); - received_pipeline.connect(new StoreMessageListener(stream_interactor)); + received_pipeline.connect(new StoreMessageListener(this, stream_interactor)); received_pipeline.connect(new StoreContentItemListener(stream_interactor)); received_pipeline.connect(new MamMessageListener(stream_interactor)); @@ -233,6 +233,67 @@ public class MessageProcessor : StreamInteractionModule, Object { return Entities.Message.Type.CHAT; } + private bool is_duplicate(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Account account = conversation.account; + + // Deduplicate by server_id + if (message.server_id != null) { + QueryBuilder builder = db.message.select() + .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); + + // If the message is a duplicate + if (builder.count() > 0) { + history_sync.on_server_id_duplicate(account, stanza, message); + return true; + } + } + + // Deduplicate messages by uuid + bool is_uuid = message.stanza_id != null && Regex.match_simple("""[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}""", message.stanza_id); + if (is_uuid) { + QueryBuilder builder = db.message.select() + .with(db.message.stanza_id, "=", message.stanza_id) + .with(db.message.counterpart_id, "=", db.get_jid_id(message.counterpart)) + .with(db.message.account_id, "=", account.id); + if (message.direction == Message.DIRECTION_RECEIVED) { + if (message.counterpart.resourcepart != null) { + builder.with(db.message.counterpart_resource, "=", message.counterpart.resourcepart); + } else { + builder.with_null(db.message.counterpart_resource); + } + } else if (message.direction == Message.DIRECTION_SENT) { + if (message.ourpart.resourcepart != null) { + builder.with(db.message.our_resource, "=", message.ourpart.resourcepart); + } else { + builder.with_null(db.message.our_resource); + } + } + bool duplicate = builder.single().row().is_present(); + return duplicate; + } + + // Deduplicate messages based on content and metadata + QueryBuilder builder = db.message.select() + .with(db.message.account_id, "=", account.id) + .with(db.message.counterpart_id, "=", db.get_jid_id(message.counterpart)) + .with(db.message.body, "=", message.body) + .with(db.message.time, "<", (long) message.time.add_minutes(1).to_unix()) + .with(db.message.time, ">", (long) message.time.add_minutes(-1).to_unix()); + if (message.stanza_id != null) { + builder.with(db.message.stanza_id, "=", message.stanza_id); + } else { + builder.with_null(db.message.stanza_id); + } + if (message.counterpart.resourcepart != null) { + builder.with(db.message.counterpart_resource, "=", message.counterpart.resourcepart); + } else { + builder.with_null(db.message.counterpart_resource); + } + return builder.count() > 0; + } + private class DeduplicateMessageListener : MessageListener { public string[] after_actions_const = new string[]{ "FILTER_EMPTY", "MUC" }; @@ -240,73 +301,13 @@ public class MessageProcessor : StreamInteractionModule, Object { public override string[] after_actions { get { return after_actions_const; } } private MessageProcessor outer; - private Database db; - public DeduplicateMessageListener(MessageProcessor outer, Database db) { + public DeduplicateMessageListener(MessageProcessor outer) { this.outer = outer; - this.db = db; } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - Account account = conversation.account; - - - // Deduplicate by server_id - if (message.server_id != null) { - QueryBuilder builder = db.message.select() - .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); - - // If the message is a duplicate - if (builder.count() > 0) { - outer.history_sync.on_server_id_duplicate(account, stanza, message); - return true; - } - } - - // Deduplicate messages by uuid - bool is_uuid = message.stanza_id != null && Regex.match_simple("""[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}""", message.stanza_id); - if (is_uuid) { - QueryBuilder builder = db.message.select() - .with(db.message.stanza_id, "=", message.stanza_id) - .with(db.message.counterpart_id, "=", db.get_jid_id(message.counterpart)) - .with(db.message.account_id, "=", account.id); - if (message.direction == Message.DIRECTION_RECEIVED) { - if (message.counterpart.resourcepart != null) { - builder.with(db.message.counterpart_resource, "=", message.counterpart.resourcepart); - } else { - builder.with_null(db.message.counterpart_resource); - } - } else if (message.direction == Message.DIRECTION_SENT) { - if (message.ourpart.resourcepart != null) { - builder.with(db.message.our_resource, "=", message.ourpart.resourcepart); - } else { - builder.with_null(db.message.our_resource); - } - } - bool duplicate = builder.single().row().is_present(); - return duplicate; - } - - // Deduplicate messages based on content and metadata - QueryBuilder builder = db.message.select() - .with(db.message.account_id, "=", account.id) - .with(db.message.counterpart_id, "=", db.get_jid_id(message.counterpart)) - .with(db.message.body, "=", message.body) - .with(db.message.time, "<", (long) message.time.add_minutes(1).to_unix()) - .with(db.message.time, ">", (long) message.time.add_minutes(-1).to_unix()); - if (message.stanza_id != null) { - builder.with(db.message.stanza_id, "=", message.stanza_id); - } else { - builder.with_null(db.message.stanza_id); - } - if (message.counterpart.resourcepart != null) { - builder.with(db.message.counterpart_resource, "=", message.counterpart.resourcepart); - } else { - builder.with_null(db.message.counterpart_resource); - } - return builder.count() > 0; + return outer.is_duplicate(message, stanza, conversation); } } @@ -327,14 +328,17 @@ public class MessageProcessor : StreamInteractionModule, Object { public override string action_group { get { return "STORE"; } } public override string[] after_actions { get { return after_actions_const; } } + private MessageProcessor outer; private StreamInteractor stream_interactor; - public StoreMessageListener(StreamInteractor stream_interactor) { + public StoreMessageListener(MessageProcessor outer, StreamInteractor stream_interactor) { + this.outer = outer; this.stream_interactor = stream_interactor; } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { - if (message.body == null) return true; + if (message.body == null || outer.is_duplicate(message, stanza, conversation)) return true; + stream_interactor.get_module(MessageStorage.IDENTITY).add_message(message, conversation); return false; } @@ -459,7 +463,7 @@ public class MessageProcessor : StreamInteractionModule, Object { if (!conversation.type_.is_muc_semantic() && current_own_jid != null && !current_own_jid.equals(message.ourpart)) { message.ourpart = current_own_jid; } - } catch (IOStreamError e) { + } catch (IOError e) { message.marked = Entities.Message.Marked.UNSENT; if (stream != stream_interactor.get_stream(conversation.account)) { @@ -484,17 +488,7 @@ public class MessageProcessor : StreamInteractionModule, Object { Xep.Replies.set_reply_to(new_stanza, new Xep.Replies.ReplyTo(quoted_sender, quoted_stanza_id)); } - string fallback = "> "; - - if (content_item.type_ == MessageItem.TYPE) { - Message? quoted_message = ((MessageItem) content_item).message; - fallback += Dino.message_body_without_reply_fallback(quoted_message); - fallback = fallback.replace("\n", "\n> "); - } else if (content_item.type_ == FileItem.TYPE) { - FileTransfer? quoted_file = ((FileItem) content_item).file_transfer; - fallback += quoted_file.file_name; - } - fallback += "\n"; + string fallback = FallbackBody.get_quoted_fallback_body(content_item); long fallback_length = fallback.length; var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback_length); diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 36a5599f..f796c36f 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -23,6 +23,7 @@ public class MucManager : StreamInteractionModule, Object { private StreamInteractor stream_interactor; private HashMap> mucs_todo = new HashMap>(Account.hash_func, Account.equals_func); private HashMap> mucs_joining = new HashMap>(Account.hash_func, Account.equals_func); + private HashMap> mucs_sync_cancellables = new HashMap>(Account.hash_func, Account.equals_func); private HashMap enter_errors = new HashMap(Jid.hash_func, Jid.equals_func); private ReceivedMessageListener received_message_listener; private HashMap bookmarks_provider = new HashMap(Account.hash_func, Account.equals_func); @@ -56,7 +57,7 @@ public class MucManager : StreamInteractionModule, Object { } // already_autojoin: Without this flag we'd be retrieving bookmarks (to check for autojoin) from the sender on every join - public async Muc.JoinResult? join(Account account, Jid jid, string? nick, string? password, bool already_autojoin = false) { + public async Muc.JoinResult? join(Account account, Jid jid, string? nick, string? password, bool already_autojoin = false, Cancellable? cancellable = null) { XmppStream? stream = stream_interactor.get_stream(account); if (stream == null) return null; @@ -102,14 +103,22 @@ public class MucManager : StreamInteractionModule, Object { stream_interactor.get_module(ConversationManager.IDENTITY).start_conversation(joined_conversation); if (can_do_mam) { + var history_sync = stream_interactor.get_module(MessageProcessor.IDENTITY).history_sync; 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)); + yield history_sync.fetch_latest_page(account, jid.bare_jid, null, new DateTime.from_unix_utc(0), cancellable); } 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); + if (!mucs_sync_cancellables.has_key(account)) { + mucs_sync_cancellables[account] = new HashMap(); + } + if (!mucs_sync_cancellables[account].has_key(jid.bare_jid)) { + mucs_sync_cancellables[account][jid.bare_jid] = new Cancellable(); + history_sync.fetch_everything.begin(account, jid.bare_jid, mucs_sync_cancellables[account][jid.bare_jid], conversation.active_last_changed, (_, res) => { + history_sync.fetch_everything.end(res); + mucs_sync_cancellables[account].unset(jid.bare_jid); + }); + } } } } else if (res.muc_error != null) { @@ -132,6 +141,14 @@ public class MucManager : StreamInteractionModule, Object { Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid, account); if (conversation != null) stream_interactor.get_module(ConversationManager.IDENTITY).close_conversation(conversation); + + cancel_sync(account, jid); + } + + private void cancel_sync(Account account, Jid jid) { + if (mucs_sync_cancellables.has_key(account) && mucs_sync_cancellables[account].has_key(jid.bare_jid) && !mucs_sync_cancellables[account][jid.bare_jid].is_cancelled()) { + mucs_sync_cancellables[account][jid.bare_jid].cancel(); + } } public async DataForms.DataForm? get_config_form(Account account, Jid jid) { @@ -323,6 +340,14 @@ public class MucManager : StreamInteractionModule, Object { return null; } + public Jid? get_occupant_jid(Account account, Jid room, Jid occupant_real_jid) { + Xep.Muc.Flag? flag = get_muc_flag(account); + if (flag != null) { + return flag.get_occupant_jid(occupant_real_jid, room); + } + return null; + } + public Xep.Muc.Role? get_role(Jid jid, Account account) { Xep.Muc.Flag? flag = get_muc_flag(account); if (flag != null) { @@ -395,6 +420,7 @@ public class MucManager : StreamInteractionModule, Object { 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) => { + cancel_sync(account, jid); left(account, jid); }); stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).subject_set.connect( (stream, subject, jid) => { @@ -459,6 +485,14 @@ public class MucManager : StreamInteractionModule, Object { } private async void on_stream_negotiated(Account account, XmppStream stream) { + if (mucs_sync_cancellables.has_key(account)) { + foreach (Cancellable cancellable in mucs_sync_cancellables[account].values) { + if (!cancellable.is_cancelled()) { + cancellable.cancel(); + } + } + } + yield initialize_bookmarks_provider(account); Set? conferences = yield bookmarks_provider[account].get_conferences(stream); diff --git a/libdino/src/service/reactions.vala b/libdino/src/service/reactions.vala index be98293f..3621dab1 100644 --- a/libdino/src/service/reactions.vala +++ b/libdino/src/service/reactions.vala @@ -37,7 +37,7 @@ public class Dino.Reactions : StreamInteractionModule, Object { try { send_reactions(conversation, content_item, reactions); reaction_added(conversation.account, content_item.id, conversation.account.bare_jid, reaction); - } catch (SendError e) {} + } catch (IOError e) {} } public void remove_reaction(Conversation conversation, ContentItem content_item, string reaction) { @@ -46,7 +46,7 @@ public class Dino.Reactions : StreamInteractionModule, Object { try { send_reactions(conversation, content_item, reactions); reaction_removed(conversation.account, content_item.id, conversation.account.bare_jid, reaction); - } catch (SendError e) {} + } catch (IOError e) {} } public Gee.List get_item_reactions(Conversation conversation, ContentItem content_item) { @@ -57,38 +57,29 @@ public class Dino.Reactions : StreamInteractionModule, Object { } } - public async bool conversation_supports_reactions(Conversation conversation) { + public 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; - } - } + 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)); + bool server_supports_sid = (entity_info.has_feature_cached(conversation.account, conversation.counterpart.bare_jid, Xep.UniqueStableStanzaIDs.NS_URI)) || + (entity_info.has_feature_cached(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); + bool? supports_occupant_ids = entity_info.has_feature_cached(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) throws SendError { + private void send_reactions(Conversation conversation, ContentItem content_item, Gee.List reactions) throws IOError { string? message_id = stream_interactor.get_module(ContentItemStore.IDENTITY).get_message_id_for_content_item(conversation, content_item); - if (message_id == null) throw new SendError.Misc("No message for content_item"); + if (message_id == null) throw new IOError.FAILED("No message for content_item"); XmppStream? stream = stream_interactor.get_stream(conversation.account); - if (stream == null) throw new SendError.NoStream(""); + if (stream == null) throw new IOError.NOT_CONNECTED("No stream"); var reactions_module = stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY); @@ -96,14 +87,14 @@ public class Dino.Reactions : StreamInteractionModule, Object { reactions_module.send_reaction.begin(stream, conversation.counterpart, "groupchat", message_id, reactions); // We save the reaction when it gets reflected back to us } else if (conversation.type_ == Conversation.Type.GROUPCHAT_PM) { - reactions_module.send_reaction(stream, conversation.counterpart, "chat", message_id, reactions); + reactions_module.send_reaction.begin(stream, conversation.counterpart, "chat", message_id, reactions); } else if (conversation.type_ == Conversation.Type.CHAT) { int64 now_millis = GLib.get_real_time () / 1000; reactions_module.send_reaction.begin(stream, conversation.counterpart, "chat", message_id, reactions, (_, res) => { try { reactions_module.send_reaction.end(res); save_chat_reactions(conversation.account, conversation.account.bare_jid, content_item.id, now_millis, reactions); - } catch (SendError e) {} + } catch (IOError e) {} }); } } @@ -145,12 +136,19 @@ public class Dino.Reactions : StreamInteractionModule, Object { return ret; } - private ReactionsTime get_muc_user_reactions(Account account, int content_item_id, string? occupantid, Jid? real_jid) { + private ReactionsTime get_muc_user_reactions(Account account, int content_item_id, string? occupant_id, Jid? real_jid) { + if (occupant_id == null && real_jid == null) critical("Need occupant id or real jid of a reaction"); + 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); + .outer_join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id); + + if (occupant_id != null) { + query.with(db.occupantid.occupant_id, "=", occupant_id); + } else if (real_jid != null) { + query.with(db.reaction.jid_id, "=", db.get_jid_id(real_jid)); + } RowOption row = query.single().row(); ReactionsTime ret = new ReactionsTime(); @@ -200,7 +198,8 @@ public class Dino.Reactions : StreamInteractionModule, Object { 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) + .outer_join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id) + .outer_join_with(db.jid, db.jid.id, db.reaction.jid_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); @@ -211,11 +210,17 @@ public class Dino.Reactions : StreamInteractionModule, Object { string emoji_str = row[db.reaction.emojis]; Jid jid = null; - if (row[db.occupantid.occupant_id] == own_occupant_id) { - jid = account.bare_jid; + if (!db.jid.bare_jid.is_null(row)) { + jid = new Jid(row[db.jid.bare_jid]); + } else if (!db.occupantid.occupant_id.is_null(row)) { + 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); + } } else { - string nick = row[db.occupantid.last_nick]; - jid = content_item.jid.with_resource(nick); + warning("Reaction with neither JID nor occupant id"); } foreach (string emoji in emoji_str.split(",")) { @@ -240,7 +245,7 @@ public class Dino.Reactions : StreamInteractionModule, Object { 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); + bool muc_supports_reactions = conversation_supports_reactions(muc_conversation); if (!muc_supports_reactions) return; } diff --git a/libdino/src/service/replies.vala b/libdino/src/service/replies.vala index 97db70ee..2bb10e0b 100644 --- a/libdino/src/service/replies.vala +++ b/libdino/src/service/replies.vala @@ -51,8 +51,6 @@ public class Dino.Replies : StreamInteractionModule, Object { private void on_incoming_message(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { // Check if a previous message was in reply to this one - string relevant_id = conversation.type_ == Conversation.Type.GROUPCHAT ? message.server_id : message.stanza_id; - var reply_qry = db.reply.select(); if (conversation.type_ == Conversation.Type.GROUPCHAT) { reply_qry.with(db.reply.quoted_message_stanza_id, "=", message.server_id); diff --git a/libdino/src/util/display_name.vala b/libdino/src/util/display_name.vala index 0c05eda8..b591eb0a 100644 --- a/libdino/src/util/display_name.vala +++ b/libdino/src/util/display_name.vala @@ -20,17 +20,10 @@ namespace Dino { } public static string get_participant_display_name(StreamInteractor stream_interactor, Conversation conversation, Jid participant, string? self_word = null) { - if (self_word != null) { - if (conversation.account.bare_jid.equals_bare(participant) || - (conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) && - conversation.nickname != null && participant.equals_bare(conversation.counterpart) && conversation.nickname == participant.resourcepart) { - return self_word; - } - } if (conversation.type_ == Conversation.Type.CHAT) { return get_real_display_name(stream_interactor, conversation.account, participant, self_word) ?? participant.bare_jid.to_string(); } - if ((conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) && conversation.counterpart.equals_bare(participant)) { + if ((conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM)) { return get_occupant_display_name(stream_interactor, conversation, participant); } return participant.bare_jid.to_string(); @@ -38,7 +31,7 @@ namespace Dino { public static string? get_real_display_name(StreamInteractor stream_interactor, Account account, Jid jid, string? self_word = null) { if (jid.equals_bare(account.bare_jid)) { - if (self_word != null || account.alias == null || account.alias.length == 0) { + if (self_word != null && (account.alias == null || account.alias.length == 0)) { return self_word; } return account.alias; @@ -75,8 +68,13 @@ namespace Dino { public static string get_occupant_display_name(StreamInteractor stream_interactor, Conversation conversation, Jid jid, string? self_word = null, bool muc_real_name = false) { if (muc_real_name) { MucManager muc_manager = stream_interactor.get_module(MucManager.IDENTITY); - if (muc_manager.is_private_room(conversation.account, jid.bare_jid)) { - Jid? real_jid = muc_manager.get_real_jid(jid, conversation.account); + if (muc_manager.is_private_room(conversation.account, conversation.counterpart)) { + Jid? real_jid = null; + if (jid.equals_bare(conversation.counterpart)) { + muc_manager.get_real_jid(jid, conversation.account); + } else { + real_jid = jid; + } if (real_jid != null) { string? display_name = get_real_display_name(stream_interactor, conversation.account, real_jid, self_word); if (display_name != null) return display_name; @@ -92,6 +90,15 @@ namespace Dino { } } + // If it's someone else's real jid, recover nickname + if (!jid.equals_bare(conversation.counterpart)) { + MucManager muc_manager = stream_interactor.get_module(MucManager.IDENTITY); + Jid? occupant_jid = muc_manager.get_occupant_jid(conversation.account, conversation.counterpart.bare_jid, jid); + if (occupant_jid != null && occupant_jid.resourcepart != null) { + return occupant_jid.resourcepart; + } + } + return jid.resourcepart ?? jid.to_string(); } } \ No newline at end of file diff --git a/libdino/src/util/weak_map.vala b/libdino/src/util/weak_map.vala index 0fd9d55d..a7f4bc44 100644 --- a/libdino/src/util/weak_map.vala +++ b/libdino/src/util/weak_map.vala @@ -22,12 +22,11 @@ public class WeakMap : Gee.AbstractMap { hash_map = new HashMap(); notify_map = new HashMap(); } else { - hash_map = new HashMap((v) => { return this.key_hash_func(v); }, - (a, b) => { return this.key_equal_func(a, b); }, - (a, b) => { return this.value_equal_func(a, b); }); - notify_map = new HashMap((v) => { return this.key_hash_func(v); }, - (a, b) => { return this.key_equal_func(a, b); }, - (a, b) => { return this.value_equal_func(a, b); }); + hash_map = new HashMap((v) => { return this.key_hash_func != null ? this.key_hash_func(v) : 0; }, + (a, b) => { return this.key_equal_func != null ? this.key_equal_func(a, b) : a == b; }, + (a, b) => { return this.value_equal_func != null ? this.value_equal_func(a, b) : a == b; }); + notify_map = new HashMap((v) => { return this.key_hash_func != null ? this.key_hash_func(v) : 0; }, + (a, b) => { return this.key_equal_func != null ? this.key_equal_func(a, b) : a == b; }); } } @@ -49,7 +48,7 @@ public class WeakMap : Gee.AbstractMap { } public override bool has(K key, V value) { - assert_not_reached(); + return has_key(key) && (this.value_equal_func != null ? this.value_equal_func(hash_map[key], value) : hash_map[key] == value); } public override bool has_key(K key) { diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index a2f63cb1..127059d5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -165,6 +165,7 @@ SOURCES src/ui/conversation_content_view/file_default_widget.vala src/ui/conversation_content_view/file_image_widget.vala src/ui/conversation_content_view/file_widget.vala + src/ui/conversation_content_view/item_actions.vala src/ui/conversation_content_view/message_widget.vala src/ui/conversation_content_view/quote_widget.vala src/ui/conversation_content_view/reactions_widget.vala @@ -208,6 +209,7 @@ SOURCES src/ui/util/sizing_bin.vala src/ui/util/size_request_box.vala + src/ui/widgets/date_separator.vala src/ui/widgets/fixed_ratio_picture.vala src/ui/widgets/natural_size_increase.vala CUSTOM_VAPIS diff --git a/main/data/icons/scalable/apps/im.dino.Dino-symbolic.svg b/main/data/icons/scalable/apps/im.dino.Dino-symbolic.svg index 00680734..55023e05 100644 --- a/main/data/icons/scalable/apps/im.dino.Dino-symbolic.svg +++ b/main/data/icons/scalable/apps/im.dino.Dino-symbolic.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/main/data/im.dino.Dino.appdata.xml b/main/data/im.dino.Dino.appdata.xml index 1b0476e7..cc8d35ca 100644 --- a/main/data/im.dino.Dino.appdata.xml +++ b/main/data/im.dino.Dino.appdata.xml @@ -165,6 +165,11 @@ https://hosted.weblate.org/projects/dino/ appstream@dino.im + + +

The 0.4 release adds support for message reactions and replies. Also, Dino is ported to GTK4.

+ +

The 0.3 release is all about calls. Dino now supports calls between two or more people!

diff --git a/main/data/im.dino.Dino.appdata.xml.in b/main/data/im.dino.Dino.appdata.xml.in index 5c301df5..9e87f39e 100644 --- a/main/data/im.dino.Dino.appdata.xml.in +++ b/main/data/im.dino.Dino.appdata.xml.in @@ -30,6 +30,11 @@ https://hosted.weblate.org/projects/dino/ appstream@dino.im + + +

The 0.4 release adds support for message reactions and replies. Also, Dino is ported to GTK4.

+
+

The 0.3 release is all about calls. Dino now supports calls between two or more people!

diff --git a/main/data/menu_app.ui b/main/data/menu_app.ui index 55eb9551..f269e219 100644 --- a/main/data/menu_app.ui +++ b/main/data/menu_app.ui @@ -6,12 +6,12 @@ app.accounts Accounts - - app.settings - Settings -
+ + app.settings + Preferences + app.open_shortcuts Keyboard Shortcuts diff --git a/main/data/settings_dialog.ui b/main/data/settings_dialog.ui index 84d56c1d..a8b24135 100644 --- a/main/data/settings_dialog.ui +++ b/main/data/settings_dialog.ui @@ -1,66 +1,69 @@ -