From fe45ab575c687febc1f342b0882a7597bd6ae9dc Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 26 May 2024 17:21:04 +0200 Subject: Support avatar deletion --- libdino/src/service/avatar_manager.vala | 43 +++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 13 deletions(-) (limited to 'libdino') diff --git a/libdino/src/service/avatar_manager.vala b/libdino/src/service/avatar_manager.vala index 3bd38e72..f99f37d4 100644 --- a/libdino/src/service/avatar_manager.vala +++ b/libdino/src/service/avatar_manager.vala @@ -160,30 +160,32 @@ public class AvatarManager : StreamInteractionModule, Object { } } + public void unset_avatar(Account account) { + XmppStream stream = stream_interactor.get_stream(account); + if (stream == null) return; + Xmpp.Xep.UserAvatars.unset_avatar(stream); + } + private void on_account_added(Account account) { stream_interactor.module_manager.get_module(account, Xep.UserAvatars.Module.IDENTITY).received_avatar_hash.connect((stream, jid, id) => - on_user_avatar_received.begin(account, jid, id) + on_user_avatar_received(account, jid, id) ); + stream_interactor.module_manager.get_module(account, Xep.UserAvatars.Module.IDENTITY).avatar_removed.connect((stream, jid) => { + on_user_avatar_removed(account, jid); + }); stream_interactor.module_manager.get_module(account, Xep.VCard.Module.IDENTITY).received_avatar_hash.connect((stream, jid, id) => - on_vcard_avatar_received.begin(account, jid, id) + on_vcard_avatar_received(account, jid, id) ); foreach (var entry in get_avatar_hashes(account, Source.USER_AVATARS).entries) { - on_user_avatar_received.begin(account, entry.key, entry.value); + on_user_avatar_received(account, entry.key, entry.value); } foreach (var entry in get_avatar_hashes(account, Source.VCARD).entries) { - - // FIXME: remove. temporary to remove falsely saved avatars. - if (stream_interactor.get_module(MucManager.IDENTITY).is_groupchat(entry.key, account)) { - db.avatar.delete().with(db.avatar.jid_id, "=", db.get_jid_id(entry.key)).perform(); - continue; - } - - on_vcard_avatar_received.begin(account, entry.key, entry.value); + on_vcard_avatar_received(account, entry.key, entry.value); } } - private async void on_user_avatar_received(Account account, Jid jid_, string id) { + private void on_user_avatar_received(Account account, Jid jid_, string id) { Jid jid = jid_.bare_jid; if (!user_avatars.has_key(jid) || user_avatars[jid] != id) { @@ -193,7 +195,14 @@ public class AvatarManager : StreamInteractionModule, Object { received_avatar(jid, account); } - private async void on_vcard_avatar_received(Account account, Jid jid_, string id) { + private void on_user_avatar_removed(Account account, Jid jid_) { + Jid jid = jid_.bare_jid; + user_avatars.unset(jid); + remove_avatar_hash(account, jid, Source.USER_AVATARS); + received_avatar(jid, account); + } + + private void on_vcard_avatar_received(Account account, Jid jid_, string id) { bool is_gc = stream_interactor.get_module(MucManager.IDENTITY).might_be_groupchat(jid_.bare_jid, account); Jid jid = is_gc ? jid_ : jid_.bare_jid; @@ -215,6 +224,14 @@ public class AvatarManager : StreamInteractionModule, Object { .perform(); } + public void remove_avatar_hash(Account account, Jid jid, int type) { + db.avatar.delete() + .with(db.avatar.jid_id, "=", db.get_jid_id(jid)) + .with(db.avatar.account_id, "=", account.id) + .with(db.avatar.type_, "=", type) + .perform(); + } + public HashMap get_avatar_hashes(Account account, int type) { HashMap ret = new HashMap(Jid.hash_func, Jid.equals_func); foreach (Row row in db.avatar.select({db.avatar.jid_id, db.avatar.hash}) -- cgit v1.2.3-70-g09d2 From f1be90c02f26c942e67978fd6d10ff2feeec8f9e Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 26 May 2024 17:28:28 +0200 Subject: Add logic for OMEMO by default setting --- libdino/src/entity/conversation.vala | 2 +- libdino/src/entity/settings.vala | 18 ++++++++++++++ libdino/src/service/conversation_manager.vala | 8 +++++++ libdino/src/service/database.vala | 32 +++++++++++++++++++++++-- plugins/omemo/src/ui/encryption_list_entry.vala | 7 +++++- 5 files changed, 63 insertions(+), 4 deletions(-) (limited to 'libdino') diff --git a/libdino/src/entity/conversation.vala b/libdino/src/entity/conversation.vala index 353daeae..4115ae83 100644 --- a/libdino/src/entity/conversation.vala +++ b/libdino/src/entity/conversation.vala @@ -33,7 +33,7 @@ public class Conversation : Object { } } } - public Encryption encryption { get; set; default = Encryption.NONE; } + public Encryption encryption { get; set; default = Encryption.UNKNOWN; } public Message? read_up_to { get; set; } public int read_up_to_item { get; set; default=-1; } diff --git a/libdino/src/entity/settings.vala b/libdino/src/entity/settings.vala index 0b09e9b9..be275efc 100644 --- a/libdino/src/entity/settings.vala +++ b/libdino/src/entity/settings.vala @@ -79,6 +79,24 @@ public class Settings : Object { check_spelling_ = value; } } + + public Encryption get_default_encryption(Account account) { + string? setting = db.account_settings.get_value(account.id, "default-encryption"); + if (setting != null) { + return (Encryption) int.parse(setting); + } + return Encryption.NONE; + } + + public void set_default_encryption(Account account, Encryption encryption) { + db.account_settings.upsert() + .value(db.account_settings.key, "default-encryption", true) + .value(db.account_settings.account_id, account.id, true) + .value(db.account_settings.value, ((int)encryption).to_string()) + .perform(); + + + } } } diff --git a/libdino/src/service/conversation_manager.vala b/libdino/src/service/conversation_manager.vala index f966ccc7..a757e8af 100644 --- a/libdino/src/service/conversation_manager.vala +++ b/libdino/src/service/conversation_manager.vala @@ -48,6 +48,14 @@ public class ConversationManager : StreamInteractionModule, Object { // Create a new converation Conversation conversation = new Conversation(jid, account, type); + // Set encryption for conversation + if (type == Conversation.Type.CHAT || + (type == Conversation.Type.GROUPCHAT && stream_interactor.get_module(MucManager.IDENTITY).is_private_room(account, jid))) { + conversation.encryption = Application.get_default().settings.get_default_encryption(account); + } else { + conversation.encryption = Encryption.NONE; + } + add_conversation(conversation); conversation.persist(db); return conversation; diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index dc1d68f3..eba8b7ca 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 26; + private const int VERSION = 27; public class AccountTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -354,6 +354,29 @@ public class Database : Qlite.Database { } } + public class AccountSettingsTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column key = new Column.Text("key") { not_null = true }; + public Column value = new Column.Text("value"); + + internal AccountSettingsTable(Database db) { + base(db, "account_settings"); + init({id, account_id, key, value}); + unique({account_id, key}, "REPLACE"); + } + + public string? get_value(int account_id, string key) { + var row_opt = select({value}) + .with(this.account_id, "=", account_id) + .with(this.key, "=", key) + .single() + .row(); + if (row_opt.is_present()) return row_opt[value]; + return null; + } + } + public class ConversationSettingsTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column conversation_id = new Column.Integer("conversation_id") {not_null=true}; @@ -388,6 +411,7 @@ public class Database : Qlite.Database { public MamCatchupTable mam_catchup { get; private set; } public ReactionTable reaction { get; private set; } public SettingsTable settings { get; private set; } + public AccountSettingsTable account_settings { get; private set; } public ConversationSettingsTable conversation_settings { get; private set; } public Map jid_table_cache = new HashMap(); @@ -417,8 +441,9 @@ public class Database : Qlite.Database { mam_catchup = new MamCatchupTable(this); reaction = new ReactionTable(this); settings = new SettingsTable(this); + account_settings = new AccountSettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, conversation_settings }); + init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, account_settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); @@ -576,6 +601,9 @@ public class Database : Qlite.Database { foreach(Row row in account.select()) { try { Account account = new Account.from_row(this, row); + if (account_table_cache.has_key(account.id)) { + account = account_table_cache[account.id]; + } ret.add(account); account_table_cache[account.id] = account; } catch (InvalidJidError e) { diff --git a/plugins/omemo/src/ui/encryption_list_entry.vala b/plugins/omemo/src/ui/encryption_list_entry.vala index b262ef81..3bb76c52 100644 --- a/plugins/omemo/src/ui/encryption_list_entry.vala +++ b/plugins/omemo/src/ui/encryption_list_entry.vala @@ -53,7 +53,12 @@ public class EncryptionListEntry : Plugins.EncryptionListEntry, Object { Manager omemo_manager = plugin.app.stream_interactor.get_module(Manager.IDENTITY); if (muc_manager.is_private_room(conversation.account, conversation.counterpart)) { - foreach (Jid offline_member in muc_manager.get_offline_members(conversation.counterpart, conversation.account)) { + var offline_members = muc_manager.get_offline_members(conversation.counterpart, conversation.account); + if (offline_members == null) { + // We don't store offline members yet, and it'll be null if we're offline + return; + } + foreach (Jid offline_member in offline_members) { bool ok = yield omemo_manager.ensure_get_keys_for_jid(conversation.account, offline_member); if (!ok) { input_status_callback(new Plugins.InputFieldStatus("A member does not support OMEMO: %s".printf(offline_member.to_string()), Plugins.InputFieldStatus.MessageType.ERROR, Plugins.InputFieldStatus.InputState.NO_SEND)); -- cgit v1.2.3-70-g09d2 From c8b20d0f5f33fb8b9898d216c3b4c9280abf31da Mon Sep 17 00:00:00 2001 From: fiaxh Date: Sun, 26 May 2024 18:24:54 +0200 Subject: Store requested disco results with computed hash, use for offline determining of private MUCs --- libdino/src/service/entity_info.vala | 35 ++++++++++++++++++++++++++++++----- libdino/src/service/muc_manager.vala | 11 ++--------- qlite/src/upsert_builder.vala | 8 ++++++-- 3 files changed, 38 insertions(+), 16 deletions(-) (limited to 'libdino') diff --git a/libdino/src/service/entity_info.vala b/libdino/src/service/entity_info.vala index d1217e81..83e27d4b 100644 --- a/libdino/src/service/entity_info.vala +++ b/libdino/src/service/entity_info.vala @@ -90,6 +90,20 @@ public class EntityInfo : StreamInteractionModule, Object { return info_result.features.contains(feature); } + public bool has_feature_offline(Account account, Jid jid, string feature) { + int ret = has_feature_cached_int(account, jid, feature); + if (ret == -1) { + return db.entity.select() + .with(db.entity.account_id, "=", account.id) + .with(db.entity.jid_id, "=", db.get_jid_id(jid)) + .with(db.entity.resource, "=", jid.resourcepart ?? "") + .join_with(db.entity_feature, db.entity.caps_hash, db.entity_feature.entity) + .with(db.entity_feature.feature, "=", feature) + .count() > 0; + } + return ret == 1; + } + public bool has_feature_cached(Account account, Jid jid, string feature) { return has_feature_cached_int(account, jid, feature) == 1; } @@ -203,13 +217,24 @@ public class EntityInfo : StreamInteractionModule, Object { ServiceDiscovery.InfoResult? info_result = yield stream.get_module(ServiceDiscovery.Module.IDENTITY).request_info(stream, jid); if (info_result == null) return null; - if (hash != null && EntityCapabilities.Module.compute_hash_for_info_result(info_result) == hash) { - store_features(hash, info_result.features); - store_identities(hash, info_result.identities); + var computed_hash = EntityCapabilities.Module.compute_hash_for_info_result(info_result); + + if (hash == null || computed_hash == hash) { + db.entity.upsert() + .value(db.entity.account_id, account.id, true) + .value(db.entity.jid_id, db.get_jid_id(jid), true) + .value(db.entity.resource, jid.resourcepart ?? "", true) + .value(db.entity.last_seen, (long)(new DateTime.now_local()).to_unix()) + .value(db.entity.caps_hash, computed_hash) + .perform(); + + store_features(computed_hash, info_result.features); + store_identities(computed_hash, info_result.identities); } else { - jid_features[jid] = info_result.features; - jid_identity[jid] = info_result.identities; + warning("Claimed entity caps hash from %s doesn't match computed one", jid.to_string()); } + jid_features[jid] = info_result.features; + jid_identity[jid] = info_result.identities; return info_result; } diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 119079f0..6b52fe36 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -232,15 +232,8 @@ public class MucManager : StreamInteractionModule, Object { //the term `private room` is a short hand for members-only+non-anonymous rooms public bool is_private_room(Account account, Jid jid) { - XmppStream? stream = stream_interactor.get_stream(account); - if (stream == null) { - return false; - } - Xep.Muc.Flag? flag = stream.get_flag(Xep.Muc.Flag.IDENTITY); - if (flag == null) { - return false; - } - return flag.has_room_feature(jid, Xep.Muc.Feature.NON_ANONYMOUS) && flag.has_room_feature(jid, Xep.Muc.Feature.MEMBERS_ONLY); + var entity_info = stream_interactor.get_module(EntityInfo.IDENTITY); + return entity_info.has_feature_offline(account, jid, "muc_membersonly") && entity_info.has_feature_offline(account, jid, "muc_nonanonymous"); } public bool is_moderated_room(Account account, Jid jid) { diff --git a/qlite/src/upsert_builder.vala b/qlite/src/upsert_builder.vala index 7daf7109..79104972 100644 --- a/qlite/src/upsert_builder.vala +++ b/qlite/src/upsert_builder.vala @@ -26,9 +26,13 @@ public class UpsertBuilder : StatementBuilder { return this; } - public UpsertBuilder value_null(Column column) { + public UpsertBuilder value_null(Column column, bool key = false) { if (column.not_null) error("Can't set non-null column %s to null", column.name); - fields += new NullField(column); + if (key) { + keys += new NullField(column); + } else { + fields += new NullField(column); + } return this; } -- cgit v1.2.3-70-g09d2 From 21ae42762d8a57da5cb1ec40b46e7510fc3121ad Mon Sep 17 00:00:00 2001 From: fiaxh Date: Thu, 20 Jun 2024 12:05:20 +0200 Subject: Redesign and rewrite accounts and settings dialog into a combined one --- libdino/src/plugin/interfaces.vala | 7 + libdino/src/plugin/registry.vala | 13 + main/CMakeLists.txt | 15 +- main/data/account_picker_row.ui | 32 ++ main/data/gresource.xml | 4 + main/data/menu_app.ui | 8 +- main/data/preferences_window.ui | 32 ++ main/data/preferences_window_account.ui | 137 +++++++++ main/data/preferences_window_general.ui | 68 +++++ main/data/settings_dialog.ui | 74 ----- main/data/style-dark.css | 2 +- main/data/style.css | 4 +- main/meson.build | 8 +- main/src/ui/application.vala | 33 +- main/src/ui/notifier_freedesktop.vala | 7 +- main/src/ui/notifier_gnotifications.vala | 1 + main/src/ui/settings_dialog.vala | 30 -- main/src/view_model/account_details.vala | 21 ++ main/src/view_model/preferences_window.vala | 109 +++++++ .../account_preferences_subpage.vala | 264 ++++++++++++++++ .../accounts_preferences_page.vala | 75 +++++ .../encryption_preferences_page.vala | 59 ++++ .../general_preferences_page.vala | 39 +++ .../preferences_window/preferences_window.vala | 31 ++ plugins/omemo/CMakeLists.txt | 14 +- plugins/omemo/data/encryption_preferences_entry.ui | 81 +++++ plugins/omemo/data/gresource.xml | 1 + plugins/omemo/meson.build | 3 +- plugins/omemo/src/plugin.vala | 4 +- plugins/omemo/src/ui/account_settings_entry.vala | 58 ---- .../omemo/src/ui/encryption_preferences_entry.vala | 336 +++++++++++++++++++++ plugins/omemo/src/ui/util.vala | 46 +-- plugins/openpgp/CMakeLists.txt | 26 +- plugins/openpgp/data/account_settings_item.ui | 31 -- plugins/openpgp/meson.build | 2 +- plugins/openpgp/src/account_settings_entry.vala | 163 ---------- .../openpgp/src/encryption_preferences_entry.vala | 86 ++++++ plugins/openpgp/src/plugin.vala | 4 +- 38 files changed, 1483 insertions(+), 445 deletions(-) create mode 100644 main/data/account_picker_row.ui create mode 100644 main/data/preferences_window.ui create mode 100644 main/data/preferences_window_account.ui create mode 100644 main/data/preferences_window_general.ui delete mode 100644 main/data/settings_dialog.ui delete mode 100644 main/src/ui/settings_dialog.vala create mode 100644 main/src/view_model/account_details.vala create mode 100644 main/src/view_model/preferences_window.vala create mode 100644 main/src/windows/preferences_window/account_preferences_subpage.vala create mode 100644 main/src/windows/preferences_window/accounts_preferences_page.vala create mode 100644 main/src/windows/preferences_window/encryption_preferences_page.vala create mode 100644 main/src/windows/preferences_window/general_preferences_page.vala create mode 100644 main/src/windows/preferences_window/preferences_window.vala create mode 100644 plugins/omemo/data/encryption_preferences_entry.ui delete mode 100644 plugins/omemo/src/ui/account_settings_entry.vala create mode 100644 plugins/omemo/src/ui/encryption_preferences_entry.vala delete mode 100644 plugins/openpgp/data/account_settings_item.ui delete mode 100644 plugins/openpgp/src/account_settings_entry.vala create mode 100644 plugins/openpgp/src/encryption_preferences_entry.vala (limited to 'libdino') diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index cfe4d0cb..dd25c5f5 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -55,6 +55,13 @@ public abstract class AccountSettingsEntry : Object { public abstract Object? get_widget(WidgetType type); } +public abstract class EncryptionPreferencesEntry : Object { + public abstract string id { get; } + public virtual Priority priority { get { return Priority.DEFAULT; } } + + public abstract Object? get_widget(Account account, WidgetType type); +} + public interface ContactDetailsProvider : Object { public abstract string id { get; } diff --git a/libdino/src/plugin/registry.vala b/libdino/src/plugin/registry.vala index 6c0234ca..7180aa14 100644 --- a/libdino/src/plugin/registry.vala +++ b/libdino/src/plugin/registry.vala @@ -6,6 +6,7 @@ public class Registry { public HashMap encryption_list_entries = new HashMap(); public HashMap call_encryption_entries = new HashMap(); public ArrayList account_settings_entries = new ArrayList(); + public ArrayList encryption_preferences_entries = new ArrayList(); public ArrayList contact_details_entries = new ArrayList(); public Map text_commands = new HashMap(); public Gee.List conversation_addition_populators = new ArrayList(); @@ -43,6 +44,18 @@ public class Registry { } } + public bool register_encryption_preferences_entry(EncryptionPreferencesEntry entry) { + lock(encryption_preferences_entries) { + foreach(var e in encryption_preferences_entries) { + if (e.id == entry.id) return false; + } + encryption_preferences_entries.add(entry); + // TODO: Order by priority +// encryption_preferences_entries.sort((a,b) => b.name.collate(a.name)); + return true; + } + } + public bool register_contact_details_entry(ContactDetailsProvider entry) { lock(contact_details_entries) { foreach(ContactDetailsProvider e in contact_details_entries) { diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ea4de99b..d5fc66be 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -59,6 +59,7 @@ set(RESOURCE_LIST add_conversation/list_row.ui add_conversation/select_jid_fragment.ui + account_picker_row.ui call_widget.ui chat_input.ui conversation_details.ui @@ -86,9 +87,11 @@ set(RESOURCE_LIST message_item_widget_edit_mode.ui occupant_list.ui occupant_list_item.ui + preferences_window.ui + preferences_window_account.ui + preferences_window_general.ui quote.ui search_autocomplete.ui - settings_dialog.ui unified_main_content.ui unified_window_placeholder.ui @@ -155,7 +158,6 @@ SOURCES src/ui/global_search.vala src/ui/notifier_freedesktop.vala src/ui/notifier_gnotifications.vala - src/ui/settings_dialog.vala src/ui/main_window.vala src/ui/main_window_controller.vala @@ -236,10 +238,19 @@ SOURCES src/ui/widgets/fixed_ratio_picture.vala src/ui/widgets/natural_size_increase.vala + src/view_model/account_details.vala src/view_model/conversation_details.vala src/view_model/preferences_row.vala + src/view_model/preferences_window.vala src/windows/conversation_details.vala + + src/windows/preferences_window/account_preferences_subpage.vala + src/windows/preferences_window/accounts_preferences_page.vala + src/windows/preferences_window/encryption_preferences_page.vala + src/windows/preferences_window/general_preferences_page.vala + src/windows/preferences_window/preferences_window.vala + CUSTOM_VAPIS ${CMAKE_BINARY_DIR}/exports/xmpp-vala.vapi ${CMAKE_BINARY_DIR}/exports/qlite.vapi diff --git a/main/data/account_picker_row.ui b/main/data/account_picker_row.ui new file mode 100644 index 00000000..a67f7b3b --- /dev/null +++ b/main/data/account_picker_row.ui @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/main/data/gresource.xml b/main/data/gresource.xml index 282838e0..f436ce68 100644 --- a/main/data/gresource.xml +++ b/main/data/gresource.xml @@ -1,6 +1,7 @@ + account_picker_row.ui add_conversation/add_contact_dialog.ui add_conversation/add_groupchat_dialog.ui add_conversation/conference_details_fragment.ui @@ -66,6 +67,9 @@ occupant_list.ui occupant_list_item.ui quote.ui + preferences_window.ui + preferences_window_account.ui + preferences_window_general.ui search_autocomplete.ui settings_dialog.ui style-dark.css diff --git a/main/data/menu_app.ui b/main/data/menu_app.ui index bb33ff65..9b85634d 100644 --- a/main/data/menu_app.ui +++ b/main/data/menu_app.ui @@ -3,13 +3,7 @@
- app.accounts - Accounts - -
-
- - app.settings + app.preferences Preferences diff --git a/main/data/preferences_window.ui b/main/data/preferences_window.ui new file mode 100644 index 00000000..d262dd76 --- /dev/null +++ b/main/data/preferences_window.ui @@ -0,0 +1,32 @@ + + + + + + \ No newline at end of file diff --git a/main/data/preferences_window_account.ui b/main/data/preferences_window_account.ui new file mode 100644 index 00000000..4280422d --- /dev/null +++ b/main/data/preferences_window_account.ui @@ -0,0 +1,137 @@ + + + + + \ No newline at end of file diff --git a/main/data/preferences_window_general.ui b/main/data/preferences_window_general.ui new file mode 100644 index 00000000..33d1a2c9 --- /dev/null +++ b/main/data/preferences_window_general.ui @@ -0,0 +1,68 @@ + + + + + diff --git a/main/data/settings_dialog.ui b/main/data/settings_dialog.ui deleted file mode 100644 index a8b24135..00000000 --- a/main/data/settings_dialog.ui +++ /dev/null @@ -1,74 +0,0 @@ - - - - diff --git a/main/data/style-dark.css b/main/data/style-dark.css index 3bd0add0..791ae9c8 100644 --- a/main/data/style-dark.css +++ b/main/data/style-dark.css @@ -1,3 +1,3 @@ -.dino-main .overlay-toolbar { +.overlay-toolbar { background-color: shade(@view_bg_color, 1.5); } \ No newline at end of file diff --git a/main/data/style.css b/main/data/style.css index a7a1d8df..5a70ba83 100644 --- a/main/data/style.css +++ b/main/data/style.css @@ -127,13 +127,13 @@ picture.avatar { /* Overlay Toolbar */ -.dino-main .overlay-toolbar { +.overlay-toolbar { padding: 2px; border-radius: 6px; border-spacing: 0; } -.dino-main .overlay-toolbar > * { +.overlay-toolbar > * { margin-top: 0; margin-bottom: 0; } diff --git a/main/meson.build b/main/meson.build index 1b5abcfc..95deabfd 100644 --- a/main/meson.build +++ b/main/meson.build @@ -77,7 +77,6 @@ sources = files( 'src/ui/occupant_menu/list.vala', 'src/ui/occupant_menu/list_row.vala', 'src/ui/occupant_menu/view.vala', - 'src/ui/settings_dialog.vala', 'src/ui/util/accounts_combo_box.vala', 'src/ui/util/config.vala', 'src/ui/util/data_forms.vala', @@ -89,8 +88,15 @@ sources = files( 'src/ui/widgets/date_separator.vala', 'src/ui/widgets/fixed_ratio_picture.vala', 'src/ui/widgets/natural_size_increase.vala', + 'src/view_model/account_details.vala', 'src/view_model/conversation_details.vala', 'src/view_model/preferences_row.vala', + 'src/view_model/preferences_window.vala', + 'src/windows/preferences_window/account_preferences_subpage.vala', + 'src/windows/preferences_window/accounts_preferences_page.vala', + 'src/windows/preferences_window/encryption_preferences_page.vala', + 'src/windows/preferences_window/general_preferences_page.vala', + 'src/windows/preferences_window/preferences_window.vala', 'src/windows/conversation_details.vala', ) sources += gnome.compile_resources( diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 2e785224..d0fde297 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -113,13 +113,17 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { } private void create_actions() { - SimpleAction accounts_action = new SimpleAction("accounts", null); - accounts_action.activate.connect(show_accounts_window); - add_action(accounts_action); - - SimpleAction settings_action = new SimpleAction("settings", null); - settings_action.activate.connect(show_settings_window); - add_action(settings_action); + SimpleAction preferences_action = new SimpleAction("preferences", null); + preferences_action.activate.connect(show_preferences_window); + add_action(preferences_action); + + SimpleAction preferences_account_action = new SimpleAction("preferences-account", VariantType.INT32); + preferences_account_action.activate.connect((variant) => { + Account? account = db.get_account_by_id(variant.get_int32()); + if (account == null) return; + show_preferences_account_window(account); + }); + add_action(preferences_account_action); SimpleAction about_action = new SimpleAction("about", null); about_action.activate.connect(show_about_window); @@ -233,17 +237,16 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { return Environment.get_variable("GTK_CSD") != "0"; } - private void show_accounts_window() { - ManageAccounts.Dialog dialog = new ManageAccounts.Dialog(stream_interactor, db); - dialog.set_transient_for(get_active_window()); - dialog.account_enabled.connect(add_connection); - dialog.account_disabled.connect(remove_connection); + private void show_preferences_window() { + Ui.PreferencesWindow dialog = new Ui.PreferencesWindow() { transient_for = window }; + dialog.model.populate(db, stream_interactor); dialog.present(); } - private void show_settings_window() { - SettingsDialog dialog = new SettingsDialog(); - dialog.set_transient_for(get_active_window()); + private void show_preferences_account_window(Account account) { + Ui.PreferencesWindow dialog = new Ui.PreferencesWindow() { transient_for = window }; + dialog.model.populate(db, stream_interactor); + dialog.accounts_page.account_chosen(account); dialog.present(); } diff --git a/main/src/ui/notifier_freedesktop.vala b/main/src/ui/notifier_freedesktop.vala index a1df5990..0d263dba 100644 --- a/main/src/ui/notifier_freedesktop.vala +++ b/main/src/ui/notifier_freedesktop.vala @@ -201,8 +201,13 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { HashTable hash_table = new HashTable(null, null); hash_table["desktop-entry"] = new Variant.string(Dino.Application.get_default().get_application_id()); hash_table["category"] = new Variant.string("im.error"); + string[] actions = new string[] {"default", "Open preferences"}; try { - yield dbus_notifications.notify("Dino", 0, "im.dino.Dino", summary, body, new string[]{}, hash_table, -1); + uint32 notification_id = yield dbus_notifications.notify("Dino", 0, "im.dino.Dino", summary, body, actions, hash_table, -1); + + add_action_listener(notification_id, "default", () => { + GLib.Application.get_default().activate_action("preferences-account", new Variant.int32(account.id)); + }); } catch (Error e) { warning("Failed showing connection error notification: %s", e.message); } diff --git a/main/src/ui/notifier_gnotifications.vala b/main/src/ui/notifier_gnotifications.vala index 4d36620d..462cdf70 100644 --- a/main/src/ui/notifier_gnotifications.vala +++ b/main/src/ui/notifier_gnotifications.vala @@ -102,6 +102,7 @@ namespace Dino.Ui { public async void notify_connection_error(Account account, ConnectionManager.ConnectionError error) { Notification notification = new Notification(_("Could not connect to %s").printf(account.bare_jid.domainpart)); + notification.set_default_action_and_target_value("app.preferences-account", new Variant.int32(account.id)); switch (error.source) { case ConnectionManager.ConnectionError.Source.SASL: notification.set_body("Wrong password"); diff --git a/main/src/ui/settings_dialog.vala b/main/src/ui/settings_dialog.vala deleted file mode 100644 index 3635879c..00000000 --- a/main/src/ui/settings_dialog.vala +++ /dev/null @@ -1,30 +0,0 @@ -using Gtk; - -namespace Dino.Ui { - -[GtkTemplate (ui = "/im/dino/Dino/settings_dialog.ui")] -class SettingsDialog : Adw.PreferencesWindow { - - [GtkChild] private unowned Switch typing_switch; - [GtkChild] private unowned Switch marker_switch; - [GtkChild] private unowned Switch notification_switch; - [GtkChild] private unowned Switch emoji_switch; - - Dino.Entities.Settings settings = Dino.Application.get_default().settings; - - public SettingsDialog() { - Object(); - - typing_switch.active = settings.send_typing; - marker_switch.active = settings.send_marker; - notification_switch.active = settings.notifications; - emoji_switch.active = settings.convert_utf8_smileys; - - typing_switch.notify["active"].connect(() => { settings.send_typing = typing_switch.active; } ); - marker_switch.notify["active"].connect(() => { settings.send_marker = marker_switch.active; } ); - notification_switch.notify["active"].connect(() => { settings.notifications = notification_switch.active; } ); - emoji_switch.notify["active"].connect(() => { settings.convert_utf8_smileys = emoji_switch.active; }); - } -} - -} diff --git a/main/src/view_model/account_details.vala b/main/src/view_model/account_details.vala new file mode 100644 index 00000000..a9ddeca5 --- /dev/null +++ b/main/src/view_model/account_details.vala @@ -0,0 +1,21 @@ +using Dino; +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; + +public class Dino.Ui.ViewModel.AccountDetails : Object { + public Entities.Account account { get; set; } + public string bare_jid { owned get { return account.bare_jid.to_string(); } } + public CompatAvatarPictureModel avatar_model { get; set; } + public ConnectionManager.ConnectionState connection_state { get; set; } + public ConnectionManager.ConnectionError? connection_error { get; set; } + + public AccountDetails(Account account, StreamInteractor stream_interactor) { + var account_conv = new Conversation(account.bare_jid, account, Conversation.Type.CHAT); + + this.account = account; + this.avatar_model = new ViewModel.CompatAvatarPictureModel(stream_interactor).set_conversation(account_conv); + this.connection_state = stream_interactor.connection_manager.get_state(account); + this.connection_error = stream_interactor.connection_manager.get_error(account); + } +} \ No newline at end of file diff --git a/main/src/view_model/preferences_window.vala b/main/src/view_model/preferences_window.vala new file mode 100644 index 00000000..9cc5a80e --- /dev/null +++ b/main/src/view_model/preferences_window.vala @@ -0,0 +1,109 @@ +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; +using Gee; + +public class Dino.Ui.ViewModel.PreferencesWindow : Object { + public signal void update(); + + public HashMap account_details = new HashMap(Account.hash_func, Account.equals_func); + public AccountDetails selected_account { get; set; } + public Gtk.SingleSelection active_accounts_selection { get; default=new Gtk.SingleSelection(new GLib.ListStore(typeof(ViewModel.AccountDetails))); } + + public StreamInteractor stream_interactor; + public Database db; + + public GeneralPreferencesPage general_page { get; set; default=new GeneralPreferencesPage(); } + + public void populate(Database db, StreamInteractor stream_interactor) { + this.db = db; + this.stream_interactor = stream_interactor; + + stream_interactor.connection_manager.connection_error.connect((account, error) => { + var account_detail = account_details[account]; + if (account_details != null) { + account_detail.connection_error = error; + } + }); + stream_interactor.connection_manager.connection_state_changed.connect((account, state) => { + var account_detail = account_details[account]; + if (account_details != null) { + account_detail.connection_state = state; + account_detail.connection_error = stream_interactor.connection_manager.get_error(account); + } + }); + stream_interactor.account_added.connect(update_data); + stream_interactor.account_removed.connect(update_data); + + bind_general_page(); + update_data(); + } + + private void update_data() { + // account_details should hold the correct set of accounts (add or remove some if needed), but do not override remaining ones (would destroy bindings) + var current_accounts = db.get_accounts(); + var remove_accounts = new ArrayList(); + foreach (var account in account_details.keys) { + if (!current_accounts.contains(account)) remove_accounts.add(account); + } + foreach (var account in remove_accounts) { + account_details.unset(account); + } + foreach (var account in current_accounts) { + if (!account_details.has_key(account)) { + account_details[account] = new AccountDetails(account, stream_interactor); + } + if (selected_account == null && account.enabled) selected_account = account_details[account]; + } + + // Update account picker model with currently active accounts + var list_model = (GLib.ListStore) active_accounts_selection.model; + list_model.remove_all(); + foreach (var account in stream_interactor.get_accounts()) { + list_model.append(new ViewModel.AccountDetails(account, stream_interactor)); + } + + update(); + } + + public void set_avatar_uri(Account account, string uri) { + stream_interactor.get_module(AvatarManager.IDENTITY).publish(account, uri); + } + + public void remove_avatar(Account account) { + stream_interactor.get_module(AvatarManager.IDENTITY).unset_avatar(account); + } + + public void remove_account(Account account) { + stream_interactor.disconnect_account.begin(account, () => { + account.remove(); + update_data(); + }); + } + + public void reconnect_account(Account account) { + stream_interactor.disconnect_account.begin(account, () => { + stream_interactor.connect_account(account); + }); + } + + public void enable_disable_account(Account account) { + if (account.enabled) { + account.enabled = false; + stream_interactor.disconnect_account.begin(account); + } else { + account.enabled = true; + stream_interactor.connect_account(account); + } + update_data(); + } + + private void bind_general_page() { + var settings = Dino.Application.get_default().settings; + settings.bind_property("send-typing", general_page, "send-typing", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("send-marker", general_page, "send-marker", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications", general_page, "notifications", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("convert-utf8-smileys", general_page, "convert-emojis", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + } +} + diff --git a/main/src/windows/preferences_window/account_preferences_subpage.vala b/main/src/windows/preferences_window/account_preferences_subpage.vala new file mode 100644 index 00000000..462fc8e1 --- /dev/null +++ b/main/src/windows/preferences_window/account_preferences_subpage.vala @@ -0,0 +1,264 @@ +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; +using Gee; +using Gtk; +using Gdk; + +[GtkTemplate (ui = "/im/dino/Dino/preferences_window_account.ui")] +public class Dino.Ui.AccountPreferencesSubpage : Gtk.Box { + + [GtkChild] public unowned Button back_button; + [GtkChild] public unowned AvatarPicture avatar; + [GtkChild] public unowned Adw.ActionRow xmpp_address; + [GtkChild] public unowned Adw.ActionRow local_alias; // TODO replace with EntryRow once we require Adw 1.2 + [GtkChild] public unowned Entry local_alias_entry; + [GtkChild] public unowned Adw.ActionRow connection_status; + [GtkChild] public unowned Button enter_password_button; + [GtkChild] public unowned Box avatar_menu_box; + [GtkChild] public unowned Button edit_avatar_button; + [GtkChild] public unowned Button remove_avatar_button; + [GtkChild] public unowned Widget button_container; + [GtkChild] public unowned Button remove_account_button; + [GtkChild] public unowned Button disable_account_button; + + public Account account { get { return model.selected_account.account; } } + public ViewModel.PreferencesWindow model { get; set; } + + private Binding[] bindings = new Binding[0]; + private ulong[] account_notify_ids = new ulong[0]; + private ulong alias_entry_changed = 0; + + construct { + button_container.layout_manager = new NaturalDirectionBoxLayout((BoxLayout)button_container.layout_manager); + back_button.clicked.connect(() => { + var window = (Adw.PreferencesWindow) this.get_root(); + window.close_subpage(); + }); + edit_avatar_button.clicked.connect(() => { + show_select_avatar(); + }); + remove_avatar_button.clicked.connect(() => { + model.remove_avatar(account); + }); + disable_account_button.clicked.connect(() => { + model.enable_disable_account(account); + }); + remove_account_button.clicked.connect(() => { + show_remove_account_dialog(); + }); + enter_password_button.clicked.connect(() => { + + var password = new PasswordEntry() { show_peek_icon=true }; +#if Adw_1_2 + var dialog = new Adw.MessageDialog((Window)this.get_root(), "Enter password for %s".printf(account.bare_jid.to_string()), null); + dialog.response.connect((response) => { + if (response == "connect") { + account.password = password.text; + model.reconnect_account(account); + } + }); + dialog.set_default_response("connect"); + dialog.set_extra_child(password); + dialog.add_response("cancel", _("Cancel")); + dialog.add_response("connect", _("Connect")); +#else + Gtk.MessageDialog dialog = new Gtk.MessageDialog ( + (Window)this.get_root(), Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL, + Gtk.MessageType.QUESTION, Gtk.ButtonsType.OK_CANCEL, + "Enter password for %s", account.bare_jid.to_string()); + Button ok_button = dialog.get_widget_for_response(ResponseType.OK) as Button; + ok_button.label = _("Connect"); + + dialog.response.connect((response) => { + if (response == ResponseType.OK) { + account.password = password.text; + model.reconnect_account(account); + } + dialog.close(); + }); + dialog.get_content_area().append(password); +#endif + + dialog.present(); + }); + + this.notify["model"].connect(() => { + model.notify["selected-account"].connect(() => { + foreach (var binding in bindings) { + binding.unbind(); + } + + avatar.model = model.selected_account.avatar_model; + xmpp_address.subtitle = account.bare_jid.to_string(); + + if (alias_entry_changed != 0) local_alias_entry.disconnect(alias_entry_changed); + local_alias_entry.text = account.alias ?? ""; + alias_entry_changed = local_alias_entry.changed.connect(() => { + account.alias = local_alias_entry.text; + }); + + bindings += account.bind_property("enabled", disable_account_button, "label", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + bool enabled_bool = (bool) from; + to = enabled_bool ? _("Disable account") : _("Enable account"); + return true; + }); + bindings += account.bind_property("enabled", avatar_menu_box, "visible", BindingFlags.SYNC_CREATE); + bindings += account.bind_property("enabled", connection_status, "visible", BindingFlags.SYNC_CREATE); + bindings += model.selected_account.bind_property("connection-state", connection_status, "subtitle", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + to = get_status_label(); + return true; + }); + bindings += model.selected_account.bind_property("connection-error", connection_status, "subtitle", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + to = get_status_label(); + return true; + }); + bindings += model.selected_account.bind_property("connection-error", enter_password_button, "visible", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + var error = (ConnectionManager.ConnectionError) from; + to = error != null && error.source == ConnectionManager.ConnectionError.Source.SASL; + return true; + }); + + model.selected_account.notify["connection-error"].connect(() => { + // TODO doesn't work + if (model.selected_account.connection_error != null) { + connection_status.add_css_class("error"); + } else { + connection_status.remove_css_class("error"); + } + }); + if (model.selected_account.connection_error != null) { + connection_status.add_css_class("error"); + } else { + connection_status.remove_css_class("error"); + } + }); + }); + } + + private void show_select_avatar() { + FileChooserNative chooser = new FileChooserNative(_("Select avatar"), (Window)this.get_root(), FileChooserAction.OPEN, _("Select"), _("Cancel")); + FileFilter filter = new FileFilter(); + foreach (PixbufFormat pixbuf_format in Pixbuf.get_formats()) { + foreach (string mime_type in pixbuf_format.get_mime_types()) { + filter.add_mime_type(mime_type); + } + } + filter.set_filter_name(_("Images")); + chooser.add_filter(filter); + + filter = new FileFilter(); + filter.set_filter_name(_("All files")); + filter.add_pattern("*"); + chooser.add_filter(filter); + + chooser.response.connect(() => { + string uri = chooser.get_file().get_path(); + model.set_avatar_uri(account, uri); + }); + + chooser.show(); + } + + private void show_remove_account_dialog() { + Gtk.MessageDialog msg = new Gtk.MessageDialog ( + (Window)this.get_root(), Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL, + Gtk.MessageType.WARNING, Gtk.ButtonsType.OK_CANCEL, + _("Remove account %s?"), account.bare_jid.to_string()); + msg.secondary_text = "You won't be able to access your conversation history anymore."; // TODO remove history! + Button ok_button = msg.get_widget_for_response(ResponseType.OK) as Button; + ok_button.label = _("Remove"); + ok_button.add_css_class("destructive-action"); + msg.response.connect((response) => { + if (response == ResponseType.OK) { + model.remove_account(account); + // Close the account subpage + var window = (Adw.PreferencesWindow) this.get_root(); + window.close_subpage(); +// window.pop_subpage(); + } + msg.close(); + }); + msg.present(); + } + + private string get_status_label() { + string? error_label = get_connection_error_description(); + if (error_label != null) return error_label; + + ConnectionManager.ConnectionState state = model.selected_account.connection_state; + switch (state) { + case ConnectionManager.ConnectionState.CONNECTING: + return _("Connecting…"); + case ConnectionManager.ConnectionState.CONNECTED: + return _("Connected"); + case ConnectionManager.ConnectionState.DISCONNECTED: + return _("Disconnected"); + } + assert_not_reached(); + } + + private string? get_connection_error_description() { + ConnectionManager.ConnectionError? error = model.selected_account.connection_error; + if (error == null) return null; + + switch (error.source) { + case ConnectionManager.ConnectionError.Source.SASL: + return _("Wrong password"); + case ConnectionManager.ConnectionError.Source.TLS: + return _("Invalid TLS certificate"); + } + if (error.identifier != null) { + return _("Error") + ": " + error.identifier; + } else { + return _("Error"); + } + } +} + +public class Dino.Ui.NaturalDirectionBoxLayout : LayoutManager { + private BoxLayout original; + private BoxLayout alternative; + + public NaturalDirectionBoxLayout(BoxLayout original) { + this.original = original; + if (original.orientation == Orientation.HORIZONTAL) { + this.alternative = new BoxLayout(Orientation.VERTICAL); + this.alternative.spacing = this.original.spacing / 2; + } + } + + public override SizeRequestMode get_request_mode(Widget widget) { + return original.orientation == Orientation.HORIZONTAL ? SizeRequestMode.HEIGHT_FOR_WIDTH : SizeRequestMode.WIDTH_FOR_HEIGHT; + } + + public override void allocate(Widget widget, int width, int height, int baseline) { + int blind_minimum, blind_natural, blind_minimum_baseline, blind_natural_baseline; + original.measure(widget, original.orientation, -1, out blind_minimum, out blind_natural, out blind_minimum_baseline, out blind_natural_baseline); + int for_size = (original.orientation == Orientation.HORIZONTAL ? width : height); + if (for_size >= blind_minimum) { + original.allocate(widget, width, height, baseline); + } else { + alternative.allocate(widget, width, height, baseline); + } + } + + public override void measure(Widget widget, Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) { + if (for_size == -1) { + original.measure(widget, orientation, -1, out minimum, out natural, out minimum_baseline, out natural_baseline); + int alt_minimum, alt_natural, alt_minimum_baseline, alt_natural_baseline; + alternative.measure(widget, orientation, -1, out alt_minimum, out alt_natural, out alt_minimum_baseline, out alt_natural_baseline); + if (alt_minimum < minimum && alt_minimum != -1) minimum = alt_minimum; + if (alt_minimum_baseline < minimum_baseline && alt_minimum_baseline != -1) minimum = alt_minimum_baseline; + } else { + Orientation other_orientation = orientation == Orientation.HORIZONTAL ? Orientation.VERTICAL : Orientation.HORIZONTAL; + int blind_minimum, blind_natural, blind_minimum_baseline, blind_natural_baseline; + original.measure(widget, other_orientation, -1, out blind_minimum, out blind_natural, out blind_minimum_baseline, out blind_natural_baseline); + if (for_size >= blind_minimum) { + original.measure(widget, orientation, for_size, out minimum, out natural, out minimum_baseline, out natural_baseline); + } else { + alternative.measure(widget, orientation, for_size, out minimum, out natural, out minimum_baseline, out natural_baseline); + } + } + } +} \ No newline at end of file diff --git a/main/src/windows/preferences_window/accounts_preferences_page.vala b/main/src/windows/preferences_window/accounts_preferences_page.vala new file mode 100644 index 00000000..7f4c2f1b --- /dev/null +++ b/main/src/windows/preferences_window/accounts_preferences_page.vala @@ -0,0 +1,75 @@ +using Dino.Entities; +using Gee; +using Gtk; + +public class Dino.Ui.PreferencesWindowAccounts : Adw.PreferencesPage { + + public signal void account_chosen(Account account); + + public Adw.PreferencesGroup active_accounts; + public Adw.PreferencesGroup disabled_accounts; + + public ViewModel.PreferencesWindow model { get; set; } + + construct { + this.title = _("Accounts"); + this.icon_name = "system-users-symbolic"; + + this.notify["model"].connect(() => { + model.update.connect(refresh); + }); + } + + private void refresh() { + if (active_accounts != null) this.remove(active_accounts); + if (disabled_accounts != null) this.remove(disabled_accounts); + + active_accounts = new Adw.PreferencesGroup() { title=_("Accounts")}; + disabled_accounts = new Adw.PreferencesGroup() { title=_("Disabled accounts")}; + Button add_account_button = new Button.from_icon_name("list-add-symbolic"); + add_account_button.add_css_class("flat"); + add_account_button.tooltip_text = _("Add Account"); + active_accounts.header_suffix = add_account_button; + + this.add(active_accounts); + this.add(disabled_accounts); + + add_account_button.clicked.connect(() => { + Ui.ManageAccounts.AddAccountDialog add_account_dialog = new Ui.ManageAccounts.AddAccountDialog(model.stream_interactor, model.db); + add_account_dialog.set_transient_for((Window)this.get_root()); + add_account_dialog.added.connect((account) => { + refresh(); + }); + add_account_dialog.present(); + }); + + disabled_accounts.visible = false; // Only display disabled section if it contains accounts + var enabled_account_added = false; + + foreach (ViewModel.AccountDetails account_details in model.account_details.values) { + var row = new Adw.ActionRow() { + title = account_details.bare_jid.to_string() + }; + row.add_prefix(new AvatarPicture() { valign=Align.CENTER, height_request=35, width_request=35, model = account_details.avatar_model }); + row.add_suffix(new Image.from_icon_name("go-next-symbolic")); + row.activatable = true; + + if (account_details.account.enabled) { + active_accounts.add(row); + enabled_account_added = true; + } else { + disabled_accounts.add(row); + disabled_accounts.visible = true; + } + + row.activated.connect(() => { + account_chosen(account_details.account); + }); + } + + // We always have to show the active accounts group for the add new button. Display placeholder if there are no active accounts + if (!enabled_account_added) { + active_accounts.add(new Adw.ActionRow() { title=_("No active accounts") }); + } + } +} diff --git a/main/src/windows/preferences_window/encryption_preferences_page.vala b/main/src/windows/preferences_window/encryption_preferences_page.vala new file mode 100644 index 00000000..2222584d --- /dev/null +++ b/main/src/windows/preferences_window/encryption_preferences_page.vala @@ -0,0 +1,59 @@ +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; +using Gee; +using Gtk; + +//[GtkTemplate (ui = "/im/dino/Dino/preferences_window_encryption.ui")] +public class Dino.Ui.PreferencesWindowEncryption : Adw.PreferencesPage { + + private DropDown drop_down = null; + private Adw.PreferencesGroup accounts_group = new Adw.PreferencesGroup(); + private ArrayList added_widgets = new ArrayList(); + + public ViewModel.PreferencesWindow model { get; set; } + + construct { + this.add(accounts_group); + + this.notify["model"].connect(() => { + this.model.update.connect(() => { + if (drop_down != null) { + accounts_group.remove(drop_down); + drop_down = null; + } + + if (model.active_accounts_selection.get_n_items() > 0) { + drop_down = new DropDown(model.active_accounts_selection, null) { halign=Align.CENTER }; + drop_down.factory = new BuilderListItemFactory.from_resource(null, "/im/dino/Dino/account_picker_row.ui"); + + drop_down.notify["selected-item"].connect(() => { + var account_details = (ViewModel.AccountDetails) drop_down.selected_item; + if (account_details == null) return; + set_account(account_details.account); + }); + + drop_down.selected = 0; + set_account(((ViewModel.AccountDetails)model.active_accounts_selection.get_item(0)).account); + } else { + drop_down = new DropDown.from_strings(new string[] { _("No active accounts")}) { halign=Align.CENTER }; + } + accounts_group.add(drop_down); + }); + }); + } + + public void set_account(Account account) { + foreach (var widget in added_widgets) { + this.remove(widget); + } + added_widgets.clear(); + + Application app = GLib.Application.get_default() as Application; + foreach (Plugins.EncryptionPreferencesEntry e in app.plugin_registry.encryption_preferences_entries) { + var widget = (Adw.PreferencesGroup) e.get_widget(account, Plugins.WidgetType.GTK4); + this.add(widget); + this.added_widgets.add(widget); + } + } +} \ No newline at end of file diff --git a/main/src/windows/preferences_window/general_preferences_page.vala b/main/src/windows/preferences_window/general_preferences_page.vala new file mode 100644 index 00000000..7aa6c2bd --- /dev/null +++ b/main/src/windows/preferences_window/general_preferences_page.vala @@ -0,0 +1,39 @@ +using Gtk; + +public class Dino.Ui.ViewModel.GeneralPreferencesPage : Object { + public bool send_typing { get; set; } + public bool send_marker { get; set; } + public bool notifications { get; set; } + public bool convert_emojis { get; set; } +} + +[GtkTemplate (ui = "/im/dino/Dino/preferences_window_general.ui")] +public class Dino.Ui.GeneralPreferencesPage : Adw.PreferencesPage { + [GtkChild] private unowned Switch typing_switch; + [GtkChild] private unowned Switch marker_switch; + [GtkChild] private unowned Switch notification_switch; + [GtkChild] private unowned Switch emoji_switch; + + public ViewModel.GeneralPreferencesPage model { get; set; default = new ViewModel.GeneralPreferencesPage(); } + private Binding[] model_bindings = new Binding[0]; + + construct { + this.notify["model"].connect(on_model_changed); + } + + private void on_model_changed() { + foreach (Binding binding in model_bindings) { + binding.unbind(); + } + if (model != null) { + model_bindings = new Binding[] { + model.bind_property("send-typing", typing_switch, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("send-marker", marker_switch, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications", notification_switch, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("convert-emojis", emoji_switch, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL) + }; + } else { + model_bindings = new Binding[0]; + } + } +} diff --git a/main/src/windows/preferences_window/preferences_window.vala b/main/src/windows/preferences_window/preferences_window.vala new file mode 100644 index 00000000..e34261e9 --- /dev/null +++ b/main/src/windows/preferences_window/preferences_window.vala @@ -0,0 +1,31 @@ +using Gdk; +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; +using Gee; +using Gtk; + +[GtkTemplate (ui = "/im/dino/Dino/preferences_window.ui")] +public class Dino.Ui.PreferencesWindow : Adw.PreferencesWindow { + [GtkChild] public unowned Dino.Ui.PreferencesWindowAccounts accounts_page; + [GtkChild] public unowned Dino.Ui.PreferencesWindowEncryption encryption_page; + [GtkChild] public unowned Dino.Ui.GeneralPreferencesPage general_page; + public Dino.Ui.AccountPreferencesSubpage account_page = new Dino.Ui.AccountPreferencesSubpage(); + + [GtkChild] public unowned ViewModel.PreferencesWindow model { get; } + + construct { + this.default_height = 500; + this.default_width = 700; + this.can_navigate_back = true; // remove once we require Adw > 1.4 + this.bind_property("model", accounts_page, "model", BindingFlags.SYNC_CREATE); + this.bind_property("model", account_page, "model", BindingFlags.SYNC_CREATE); + this.bind_property("model", encryption_page, "model", BindingFlags.SYNC_CREATE); + + accounts_page.account_chosen.connect((account) => { + model.selected_account = model.account_details[account]; + this.present_subpage(account_page); +// this.present_subpage(new Adw.NavigationPage(account_page, "Account: %s".printf(account.bare_jid.to_string()))); + }); + } +} \ No newline at end of file diff --git a/plugins/omemo/CMakeLists.txt b/plugins/omemo/CMakeLists.txt index 7ecaa0b8..9e290390 100644 --- a/plugins/omemo/CMakeLists.txt +++ b/plugins/omemo/CMakeLists.txt @@ -3,8 +3,10 @@ find_package(Gettext) include(${GETTEXT_USE_FILE}) gettext_compile(${GETTEXT_PACKAGE} SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/po TARGET_NAME ${GETTEXT_PACKAGE}-translations) +find_package(Adwaita REQUIRED) find_package(Qrencode REQUIRED) find_packages(OMEMO_PACKAGES REQUIRED + Adwaita Gee GLib GModule @@ -19,6 +21,7 @@ find_package(SignalProtocol 2.3.2 REQUIRED) set(RESOURCE_LIST contact_details_dialog.ui + encryption_preferences_entry.ui manage_key_dialog.ui ) @@ -31,6 +34,13 @@ compile_gresources( PREFIX /im/dino/Dino/omemo SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data ) +set(OMEMO_DEFINITIONS) +if(Adwaita_VERSION VERSION_GREATER_EQUAL "1.2") + set(OMEMO_DEFINITIONS ${OMEMO_DEFINITIONS} Adw_1_2) +endif() + +message(STATUS ${Adwaita_VERSION}) +message(STATUS ${Adw_1_2}) vala_precompile(OMEMO_VALA_C SOURCES @@ -65,7 +75,6 @@ SOURCES src/signal/store.vala src/signal/util.vala - src/ui/account_settings_entry.vala src/ui/bad_messages_populator.vala src/ui/call_encryption_entry.vala src/ui/contact_details_provider.vala @@ -73,6 +82,7 @@ SOURCES src/ui/device_notification_populator.vala src/ui/own_notifications.vala src/ui/encryption_list_entry.vala + src/ui/encryption_preferences_entry.vala src/ui/manage_key_dialog.vala src/ui/util.vala CUSTOM_VAPIS @@ -86,6 +96,8 @@ PACKAGES ${OMEMO_PACKAGES} GRESOURCES ${OMEMO_GRESOURCES_XML} +DEFINITIONS + ${OMEMO_DEFINITIONS} GENERATE_VAPI omemo GENERATE_HEADER diff --git a/plugins/omemo/data/encryption_preferences_entry.ui b/plugins/omemo/data/encryption_preferences_entry.ui new file mode 100644 index 00000000..7ca26224 --- /dev/null +++ b/plugins/omemo/data/encryption_preferences_entry.ui @@ -0,0 +1,81 @@ + + + + + + left + + + 10 + 10 + 10 + 10 + + + False + + + + + + diff --git a/plugins/omemo/data/gresource.xml b/plugins/omemo/data/gresource.xml index 616dcdc1..673c3df5 100644 --- a/plugins/omemo/data/gresource.xml +++ b/plugins/omemo/data/gresource.xml @@ -2,6 +2,7 @@ contact_details_dialog.ui + encryption_preferences_entry.ui manage_key_dialog.ui diff --git a/plugins/omemo/meson.build b/plugins/omemo/meson.build index 57eec2ce..05d7c265 100644 --- a/plugins/omemo/meson.build +++ b/plugins/omemo/meson.build @@ -1,5 +1,6 @@ subdir('po') dependencies = [ + dep_libadwaita, dep_crypto_vala, dep_dino, dep_gee, @@ -40,13 +41,13 @@ sources = files( 'src/signal/store.vala', 'src/signal/util.vala', 'src/trust_level.vala', - 'src/ui/account_settings_entry.vala', 'src/ui/bad_messages_populator.vala', 'src/ui/call_encryption_entry.vala', 'src/ui/contact_details_dialog.vala', 'src/ui/contact_details_provider.vala', 'src/ui/device_notification_populator.vala', 'src/ui/encryption_list_entry.vala', + 'src/ui/encryption_preferences_entry.vala', 'src/ui/manage_key_dialog.vala', 'src/ui/own_notifications.vala', 'src/ui/util.vala', diff --git a/plugins/omemo/src/plugin.vala b/plugins/omemo/src/plugin.vala index 643428a8..dfbe0780 100644 --- a/plugins/omemo/src/plugin.vala +++ b/plugins/omemo/src/plugin.vala @@ -30,7 +30,6 @@ public class Plugin : RootInterface, Object { public Dino.Application app; public Database db; public EncryptionListEntry list_entry; - public AccountSettingsEntry settings_entry; public ContactDetailsProvider contact_details_provider; public DeviceNotificationPopulator device_notification_populator; public OwnNotifications own_notifications; @@ -43,13 +42,12 @@ public class Plugin : RootInterface, Object { this.app = app; this.db = new Database(Path.build_filename(Application.get_storage_dir(), "omemo.db")); this.list_entry = new EncryptionListEntry(this); - this.settings_entry = new AccountSettingsEntry(this); this.contact_details_provider = new ContactDetailsProvider(this); this.device_notification_populator = new DeviceNotificationPopulator(this, this.app.stream_interactor); this.trust_manager = new TrustManager(this.app.stream_interactor, this.db); this.app.plugin_registry.register_encryption_list_entry(list_entry); - this.app.plugin_registry.register_account_settings_entry(settings_entry); + this.app.plugin_registry.register_encryption_preferences_entry(new OmemoPreferencesEntry(this)); this.app.plugin_registry.register_contact_details_entry(contact_details_provider); this.app.plugin_registry.register_notification_populator(device_notification_populator); this.app.plugin_registry.register_conversation_addition_populator(new BadMessagesPopulator(this.app.stream_interactor, this)); diff --git a/plugins/omemo/src/ui/account_settings_entry.vala b/plugins/omemo/src/ui/account_settings_entry.vala deleted file mode 100644 index 8736260b..00000000 --- a/plugins/omemo/src/ui/account_settings_entry.vala +++ /dev/null @@ -1,58 +0,0 @@ -using Dino.Entities; -using Gtk; - -namespace Dino.Plugins.Omemo { - -public class AccountSettingsEntry : Plugins.AccountSettingsEntry { - private Plugin plugin; - private Account account; - - private Box box = new Box(Orientation.HORIZONTAL, 0); - private Label fingerprint = new Label("...") { xalign=0 }; - private Button btn = new Button.from_icon_name("view-list-symbolic") { has_frame=false, valign=Align.CENTER, visible=false }; - - public override string id { get { return "omemo_identity_key"; }} - - public override string name { get { return "OMEMO"; }} - - public AccountSettingsEntry(Plugin plugin) { - this.plugin = plugin; - - Border border = new Button().get_style_context().get_padding(); - fingerprint.margin_top = border.top + 1; - fingerprint.margin_start = border.left + 1; - fingerprint.visible = true; - box.append(fingerprint); - - btn.clicked.connect(() => { - activated(); - ContactDetailsDialog dialog = new ContactDetailsDialog(plugin, account, account.bare_jid); - dialog.set_transient_for((Window) box.get_root()); - dialog.present(); - }); - // TODO expand=false? - box.append(btn); - } - - public override Object? get_widget(WidgetType type) { - if (type != WidgetType.GTK4) return null; - return box; - } - - public override void set_account(Account account) { - this.account = account; - btn.visible = false; - Qlite.Row? row = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id).inner; - if (row == null) { - fingerprint.set_markup("%s\n%s".printf(_("Own fingerprint"), _("Will be generated on first connection"))); - } else { - string res = fingerprint_markup(fingerprint_from_base64(((!)row)[plugin.db.identity.identity_key_public_base64])); - fingerprint.set_markup("%s\n%s".printf(_("Own fingerprint"), res)); - btn.visible = true; - } - } - - public override void deactivate() { } -} - -} \ No newline at end of file diff --git a/plugins/omemo/src/ui/encryption_preferences_entry.vala b/plugins/omemo/src/ui/encryption_preferences_entry.vala new file mode 100644 index 00000000..7997f04d --- /dev/null +++ b/plugins/omemo/src/ui/encryption_preferences_entry.vala @@ -0,0 +1,336 @@ +using Qlite; +using Qrencode; +using Gee; +using Xmpp; +using Dino.Entities; +using Gtk; + +namespace Dino.Plugins.Omemo { + +public class OmemoPreferencesEntry : Plugins.EncryptionPreferencesEntry { + + OmemoPreferencesWidget widget; + Plugin plugin; + + public OmemoPreferencesEntry(Plugin plugin) { + this.plugin = plugin; + } + + public override Object? get_widget(Account account, WidgetType type) { + if (type != WidgetType.GTK4) return null; + var widget = new OmemoPreferencesWidget(plugin); + widget.set_account(account); + return widget; + } + + public override string id { get { return "omemo_preferences_entryption"; }} +} + +[GtkTemplate (ui = "/im/dino/Dino/omemo/encryption_preferences_entry.ui")] +public class OmemoPreferencesWidget : Adw.PreferencesGroup { + private Plugin plugin; + private Account account; + private Jid jid; + private int identity_id = 0; + private Signal.Store store; + private Set displayed_ids = new HashSet(); + + [GtkChild] private unowned Adw.ActionRow automatically_accept_new_row; + [GtkChild] private Switch automatically_accept_new_switch; + [GtkChild] private unowned Adw.ActionRow encrypt_by_default_row; + [GtkChild] private Switch encrypt_by_default_switch; + [GtkChild] private unowned Label new_keys_label; + + [GtkChild] private unowned Adw.PreferencesGroup keys_preferences_group; + [GtkChild] private unowned ListBox new_keys_listbox; + [GtkChild] private unowned Picture qrcode_picture; + [GtkChild] private unowned Popover qrcode_popover; + + private ArrayList keys_preferences_group_children = new ArrayList(); + + construct { + // If we set the strings in the .ui file, they don't get translated + encrypt_by_default_row.title = _("OMEMO by default"); + encrypt_by_default_row.subtitle = _("Enable OMEMO encryption for new conversations"); + automatically_accept_new_row.title = _("Encrypt to new devices"); + automatically_accept_new_row.subtitle = _("Automatically encrypt to new devices from this contact."); + new_keys_label.label = _("New keys"); + } + + public OmemoPreferencesWidget(Plugin plugin) { + this.plugin = plugin; + this.account = account; + this.jid = jid; + } + + public void set_account(Account account) { + this.account = account; + this.jid = account.bare_jid; + + automatically_accept_new_switch.set_active(plugin.db.trust.get_blind_trust(identity_id, jid.bare_jid.to_string(), true)); + automatically_accept_new_switch.state_set.connect(on_auto_accept_toggled); + + encrypt_by_default_switch.set_active(plugin.app.settings.get_default_encryption(account) != Encryption.NONE); + encrypt_by_default_switch.state_set.connect(on_omemo_by_default_toggled); + + identity_id = plugin.db.identity.get_id(account.id); + if (identity_id < 0) return; + Dino.Application? app = Application.get_default() as Dino.Application; + if (app != null) { + store = app.stream_interactor.module_manager.get_module(account, StreamModule.IDENTITY).store; + } + + redraw_key_list(); + + // Check for unknown devices + fetch_unknown_bundles(); + } + + private void redraw_key_list() { + // Remove current widgets + foreach (var widget in keys_preferences_group_children) { + keys_preferences_group.remove(widget); + } + keys_preferences_group_children.clear(); + + // Dialog opened from the account settings menu + // Show the fingerprint for this device separately with buttons for a qrcode and to copy + if(jid.equals(account.bare_jid)) { + automatically_accept_new_row.subtitle = _("New encryption keys from your other devices will be accepted automatically."); + add_own_fingerprint(); + } + + //Show the normal devicelist + var own_id = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.device_id]; + foreach (Row device in plugin.db.identity_meta.get_known_devices(identity_id, jid.to_string())) { + if(jid.equals(account.bare_jid) && device[plugin.db.identity_meta.device_id] == own_id) { + // If this is our own account, don't show this device twice (did it separately already) + continue; + } + add_fingerprint(device, (TrustLevel) device[plugin.db.identity_meta.trust_level]); + } + + //Show any new devices for which the user must decide whether to accept or reject + foreach (Row device in plugin.db.identity_meta.get_new_devices(identity_id, jid.to_string())) { + add_new_fingerprint(device); + } + } + + private static string escape_for_iri_path_segment(string s) { + // from RFC 3986, 2.2. Reserved Characters: + string SUB_DELIMS = "!$&'()*+,;="; + // from RFC 3986, 3.3. Path (pchar without unreserved and pct-encoded): + string ALLOWED_RESERVED_CHARS = SUB_DELIMS + ":@"; + return GLib.Uri.escape_string(s, ALLOWED_RESERVED_CHARS, true); + } + + private void fetch_unknown_bundles() { + Dino.Application app = Application.get_default() as Dino.Application; + XmppStream? stream = app.stream_interactor.get_stream(account); + if (stream == null) return; + StreamModule? module = stream.get_module(StreamModule.IDENTITY); + if (module == null) return; + module.bundle_fetched.connect_after((bundle_jid, device_id, bundle) => { + if (bundle_jid.equals(jid) && !displayed_ids.contains(device_id)) { + redraw_key_list(); + } + }); + foreach (Row device in plugin.db.identity_meta.get_unknown_devices(identity_id, jid.to_string())) { + try { + module.fetch_bundle(stream, new Jid(device[plugin.db.identity_meta.address_name]), device[plugin.db.identity_meta.device_id], false); + } catch (InvalidJidError e) { + warning("Ignoring device with invalid Jid: %s", e.message); + } + } + } + + private void add_own_fingerprint() { + string own_b64 = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.identity_key_public_base64]; + string fingerprint = fingerprint_from_base64(own_b64); + + var own_action_box = new Box(Orientation.HORIZONTAL, 6); + var show_qrcode_button = new MenuButton() { icon_name="dino-qr-code-symbolic", valign=Align.CENTER }; + own_action_box.append(show_qrcode_button); + var copy_button = new Button() { icon_name="edit-copy-symbolic", valign=Align.CENTER }; + copy_button.clicked.connect(() => { copy_button.get_clipboard().set_text(fingerprint); }); + own_action_box.append(copy_button); + + Adw.ActionRow action_row = new Adw.ActionRow(); + + action_row.title = "This device"; + action_row.subtitle = format_fingerprint(fingerprint_from_base64(own_b64)); + action_row.add_suffix(own_action_box); +#if Adw_1_2 + action_row.use_markup = true; + action_row.subtitle = fingerprint_markup(fingerprint_from_base64(own_b64)); +#endif + add_key_row(action_row); + + // Create and set QR code popover + int sid = plugin.db.identity.row_with(plugin.db.identity.account_id, account.id)[plugin.db.identity.device_id]; + var iri_query = @"omemo-sid-$(sid)=$(fingerprint)"; +#if GLIB_2_66 && VALA_0_50 + string iri = GLib.Uri.join(UriFlags.NONE, "xmpp", null, null, 0, jid.to_string(), iri_query, null); +#else + var iri_path_seg = escape_for_iri_path_segment(jid.to_string()); + var iri = @"xmpp:$(iri_path_seg)?$(iri_query)"; +#endif + + const int QUIET_ZONE_MODULES = 4; // MUST be at least 4 + const int MODULE_SIZE_PX = 4; // arbitrary + var qr_paintable = new QRcode(iri, 2) + .to_paintable(MODULE_SIZE_PX * qrcode_picture.scale_factor); + qrcode_picture.paintable = qr_paintable; + qrcode_picture.margin_top = qrcode_picture.margin_end = + qrcode_picture.margin_bottom = qrcode_picture.margin_start = QUIET_ZONE_MODULES * MODULE_SIZE_PX; + qrcode_popover.add_css_class("qrcode-container"); + + show_qrcode_button.popover = qrcode_popover; + } + + private void add_fingerprint(Row device, TrustLevel trust) { + string key_base64 = device[plugin.db.identity_meta.identity_key_public_base64]; + bool key_active = device[plugin.db.identity_meta.now_active]; + if (store != null) { + try { + Signal.Address address = new Signal.Address(jid.to_string(), device[plugin.db.identity_meta.device_id]); + Signal.SessionRecord? session = null; + if (store.contains_session(address)) { + session = store.load_session(address); + string session_key_base64 = Base64.encode(session.state.remote_identity_key.serialize()); + if (key_base64 != session_key_base64) { + critical("Session and database identity key mismatch!"); + key_base64 = session_key_base64; + } + } + } catch (Error e) { + print("Error while reading session store: %s", e.message); + } + } + + if (device[plugin.db.identity_meta.now_active]) { + Adw.ActionRow action_row = new Adw.ActionRow(); + action_row.activated.connect(() => { + Row updated_device = plugin.db.identity_meta.get_device(device[plugin.db.identity_meta.identity_id], device[plugin.db.identity_meta.address_name], device[plugin.db.identity_meta.device_id]); + ManageKeyDialog manage_dialog = new ManageKeyDialog(updated_device, plugin.db); + manage_dialog.set_transient_for((Gtk.Window) get_root()); + manage_dialog.present(); + manage_dialog.response.connect((response) => { + update_stored_trust(response, updated_device); + redraw_key_list(); + }); + }); + action_row.activatable = true; + action_row.title = "Other device"; + action_row.subtitle = format_fingerprint(fingerprint_from_base64(key_base64)); + string trust_str = _("Accepted"); + switch(trust) { + case TrustLevel.UNTRUSTED: + trust_str = _("Rejected"); + break; + case TrustLevel.VERIFIED: + trust_str = _("Verified"); + break; + } + + action_row.add_suffix(new Label(trust_str)); +#if Adw_1_2 + action_row.use_markup = true; + action_row.subtitle = fingerprint_markup(fingerprint_from_base64(key_base64)); +#endif + add_key_row(action_row); + } + displayed_ids.add(device[plugin.db.identity_meta.device_id]); + } + + private bool on_auto_accept_toggled(bool active) { + plugin.trust_manager.set_blind_trust(account, jid, active); + + if (active) { + int identity_id = plugin.db.identity.get_id(account.id); + if (identity_id < 0) return false; + + foreach (Row device in plugin.db.identity_meta.get_new_devices(identity_id, jid.to_string())) { + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.TRUSTED); + add_fingerprint(device, TrustLevel.TRUSTED); + } + } + return false; + } + + private bool on_omemo_by_default_toggled(bool active) { + var encryption_value = active ? Encryption.OMEMO : Encryption.NONE; + plugin.app.settings.set_default_encryption(account, encryption_value); + return false; + } + + private void update_stored_trust(int response, Row device) { + switch (response) { + case TrustLevel.TRUSTED: + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.TRUSTED); + break; + case TrustLevel.UNTRUSTED: + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.UNTRUSTED); + break; + case TrustLevel.VERIFIED: + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.VERIFIED); + plugin.trust_manager.set_blind_trust(account, jid, false); + automatically_accept_new_switch.set_active(false); + break; + } + } + + private void add_new_fingerprint(Row device) { + Adw.ActionRow action_row = new Adw.ActionRow(); + action_row.title = _("New device"); + action_row.subtitle = format_fingerprint(fingerprint_from_base64(device[plugin.db.identity_meta.identity_key_public_base64])); + +#if Adw_1_2 + action_row.use_markup = true; + action_row.subtitle = fingerprint_markup(fingerprint_from_base64(device[plugin.db.identity_meta.identity_key_public_base64])); +#endif + + Button accept_button = new Button() { visible = true, valign = Align.CENTER, hexpand = true }; + accept_button.set_icon_name("emblem-ok-symbolic"); // using .image = sets .image-button. Together with .suggested/destructive action that breaks the button Adwaita + accept_button.add_css_class("suggested-action"); + accept_button.tooltip_text = _("Accept key"); + + Button reject_button = new Button() { visible = true, valign = Align.CENTER, hexpand = true }; + reject_button.set_icon_name("action-unavailable-symbolic"); + reject_button.add_css_class("destructive-action"); + reject_button.tooltip_text = _("Reject key"); + + accept_button.clicked.connect(() => { + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.TRUSTED); + add_fingerprint(device, TrustLevel.TRUSTED); + remove_key_row(action_row); + }); + + reject_button.clicked.connect(() => { + plugin.trust_manager.set_device_trust(account, jid, device[plugin.db.identity_meta.device_id], TrustLevel.UNTRUSTED); + add_fingerprint(device, TrustLevel.UNTRUSTED); + remove_key_row(action_row); + }); + + Box control_box = new Box(Gtk.Orientation.HORIZONTAL, 0) { visible = true, hexpand = true }; + control_box.append(accept_button); + control_box.append(reject_button); + control_box.add_css_class("linked"); // .linked: Visually link the accept / reject buttons + + action_row.add_suffix(control_box); + + add_key_row(action_row); + displayed_ids.add(device[plugin.db.identity_meta.device_id]); + } + + private void add_key_row(Adw.PreferencesRow widget) { + keys_preferences_group.add(widget); + keys_preferences_group_children.add(widget); + } + + private void remove_key_row(Adw.PreferencesRow widget) { + keys_preferences_group.remove(widget); + keys_preferences_group_children.remove(widget); + } +} +} \ No newline at end of file diff --git a/plugins/omemo/src/ui/util.vala b/plugins/omemo/src/ui/util.vala index cf61ed82..e250ff4d 100644 --- a/plugins/omemo/src/ui/util.vala +++ b/plugins/omemo/src/ui/util.vala @@ -17,46 +17,24 @@ public static string fingerprint_from_base64(string b64) { } public static string fingerprint_markup(string s) { + return "" + format_fingerprint(s) + ""; +} + +public static string format_fingerprint(string s) { string markup = ""; for (int i = 0; i < s.length; i += 4) { string four_chars = s.substring(i, 4).down(); - int raw = (int) from_hex(four_chars); - uint8[] bytes = {(uint8) ((raw >> 8) & 0xff - 128), (uint8) (raw & 0xff - 128)}; - - Checksum checksum = new Checksum(ChecksumType.SHA1); - checksum.update(bytes, bytes.length); - uint8[] digest = new uint8[20]; - size_t len = 20; - checksum.get_digest(digest, ref len); - - uint8 r = digest[0]; - uint8 g = digest[1]; - uint8 b = digest[2]; - - if (r == 0 && g == 0 && b == 0) r = g = b = 1; - - double brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b; - - if (brightness < 80) { - double factor = 80.0 / brightness; - r = uint8.min(255, (uint8) (r * factor)); - g = uint8.min(255, (uint8) (g * factor)); - b = uint8.min(255, (uint8) (b * factor)); - - } else if (brightness > 180) { - double factor = 180.0 / brightness; - r = (uint8) (r * factor); - g = (uint8) (g * factor); - b = (uint8) (b * factor); - } - if (i % 32 == 0 && i != 0) markup += "\n"; - markup += @"$four_chars"; - if (i % 8 == 4 && i % 32 != 28) markup += " "; + markup += four_chars; + if (i % 16 == 12 && i % 32 != 28) { + markup += " "; + } + if (i % 8 == 4 && i % 16 != 12) { + markup += "\u00a0"; // Non-breaking space + } } - - return "" + markup + ""; + return markup; } } diff --git a/plugins/openpgp/CMakeLists.txt b/plugins/openpgp/CMakeLists.txt index 6ed7bf53..d2ac6d73 100644 --- a/plugins/openpgp/CMakeLists.txt +++ b/plugins/openpgp/CMakeLists.txt @@ -7,6 +7,7 @@ gettext_compile(${GETTEXT_PACKAGE} SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/po TAR find_packages(OPENPGP_PACKAGES REQUIRED + Adwaita Gee GLib GModule @@ -14,19 +15,10 @@ find_packages(OPENPGP_PACKAGES REQUIRED GTK4 ) -set(RESOURCE_LIST - account_settings_item.ui -) - -compile_gresources( - OPENPGP_GRESOURCES_TARGET - OPENPGP_GRESOURCES_XML - TARGET ${CMAKE_CURRENT_BINARY_DIR}/resources/resources.c - TYPE EMBED_C - RESOURCES ${RESOURCE_LIST} - PREFIX /im/dino/Dino/openpgp - SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/data -) +set(OPENPGP_DEFINITIONS) +if(Adwaita_VERSION VERSION_GREATER_EQUAL "1.4") + set(OPENPGP_DEFINITIONS ${OPENPGP_DEFINITIONS} Adw_1_4) +endif() vala_precompile(OPENPGP_VALA_C SOURCES @@ -35,10 +27,10 @@ SOURCES src/file_transfer/file_decryptor.vala src/file_transfer/file_encryptor.vala - src/account_settings_entry.vala src/contact_details_provider.vala src/database.vala src/encryption_list_entry.vala + src/encryption_preferences_entry.vala src/manager.vala src/plugin.vala src/register_plugin.vala @@ -53,12 +45,12 @@ CUSTOM_VAPIS ${CMAKE_BINARY_DIR}/exports/dino.vapi PACKAGES ${OPENPGP_PACKAGES} -GRESOURCES - ${OPENPGP_GRESOURCES_XML} +DEFINITIONS + ${OPENPGP_DEFINITIONS} ) add_definitions(${VALA_CFLAGS} -DG_LOG_DOMAIN="OpenPGP" -DGETTEXT_PACKAGE=\"${GETTEXT_PACKAGE}\" -DLOCALE_INSTALL_DIR=\"${LOCALE_INSTALL_DIR}\") -add_library(openpgp SHARED ${OPENPGP_VALA_C} ${OPENPGP_GRESOURCES_TARGET} src/gpgme_fix.c) +add_library(openpgp SHARED ${OPENPGP_VALA_C} src/gpgme_fix.c) add_dependencies(openpgp ${GETTEXT_PACKAGE}-translations) target_include_directories(openpgp PRIVATE src) target_link_libraries(openpgp libdino gpgme ${OPENPGP_PACKAGES}) diff --git a/plugins/openpgp/data/account_settings_item.ui b/plugins/openpgp/data/account_settings_item.ui deleted file mode 100644 index 56808be0..00000000 --- a/plugins/openpgp/data/account_settings_item.ui +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - label - - - 0 - 0 - - - 0 - - - - - - - - - entry - - - 1 - - - - - - \ No newline at end of file diff --git a/plugins/openpgp/meson.build b/plugins/openpgp/meson.build index 806494f2..ec6f5c0e 100644 --- a/plugins/openpgp/meson.build +++ b/plugins/openpgp/meson.build @@ -1,5 +1,6 @@ subdir('po') dependencies = [ + dep_libadwaita, dep_dino, dep_gee, dep_glib, @@ -10,7 +11,6 @@ dependencies = [ dep_xmpp_vala, ] sources = files( - 'src/account_settings_entry.vala', 'src/contact_details_provider.vala', 'src/database.vala', 'src/encryption_list_entry.vala', diff --git a/plugins/openpgp/src/account_settings_entry.vala b/plugins/openpgp/src/account_settings_entry.vala deleted file mode 100644 index 7c99942f..00000000 --- a/plugins/openpgp/src/account_settings_entry.vala +++ /dev/null @@ -1,163 +0,0 @@ -using Dino.Entities; -using Gtk; - -namespace Dino.Plugins.OpenPgp { - -public class AccountSettingsEntry : Plugins.AccountSettingsEntry { - - private Label label; - private Button button; - private ComboBox combobox; - private Stack stack; - - private Plugin plugin; - private Account current_account; - private Gee.List keys = null; - private Gtk.ListStore list_store = new Gtk.ListStore(2, typeof(string), typeof(string?)); - - public override string id { get { return "pgp_key_picker"; }} - - public override string name { get { return "OpenPGP"; }} - - public AccountSettingsEntry(Plugin plugin) { - this.plugin = plugin; - - Builder builder = new Builder.from_resource("/im/dino/Dino/openpgp/account_settings_item.ui"); - stack = (Stack) builder.get_object("stack"); - label = (Label) builder.get_object("label"); - button = (Button) builder.get_object("button"); - combobox = (ComboBox) builder.get_object("combobox"); - - CellRendererText renderer = new CellRendererText(); - renderer.set_padding(0, 0); - combobox.pack_start(renderer, true); - combobox.add_attribute(renderer, "markup", 0); - combobox.set_model(list_store); - - button.clicked.connect(on_button_clicked); - combobox.changed.connect(key_changed); - } - - public override void deactivate() { - stack.set_visible_child_name("label"); - } - - public override void set_account(Account account) { - set_account_.begin(account); - } - - private async void set_account_(Account account) { - this.current_account = account; - if (keys == null) { - yield fetch_keys(); - populate_list_store(); - } - activate_current_account(); - } - - private void on_button_clicked() { - activated(); - stack.set_visible_child_name("entry"); - combobox.grab_focus(); - combobox.popup(); - } - - private void activate_current_account() { - combobox.changed.disconnect(key_changed); - if (keys == null) { - label.set_markup(build_markup_string(_("Key publishing disabled"), _("Error in GnuPG"))); - return; - } - if (keys.size == 0) { - label.set_markup(build_markup_string(_("Key publishing disabled"), _("No keys available. Generate one!"))); - return; - } - - string? account_key = plugin.db.get_account_key(current_account); - int activate_index = 0; - for (int i = 0; i < keys.size; i++) { - GPG.Key key = keys[i]; - if (key.fpr == account_key) { - activate_index = i + 1; - } - } - combobox.active = activate_index; - - TreeIter selected; - combobox.get_active_iter(out selected); - set_label_active(selected); - - combobox.changed.connect(key_changed); - } - - private void populate_list_store() { - if (keys == null || keys.size == 0) { - return; - } - - list_store.clear(); - TreeIter iter; - list_store.append(out iter); - list_store.set(iter, 0, build_markup_string(_("Key publishing disabled"), _("Select key") + " \n "), 1, ""); - for (int i = 0; i < keys.size; i++) { - list_store.append(out iter); - list_store.set(iter, 0, @"$(Markup.escape_text(keys[i].uids[0].uid))\n$(markup_colorize_id(keys[i].fpr, true)) "); - list_store.set(iter, 1, keys[i].fpr); - if (keys[i].fpr == plugin.db.get_account_key(current_account)) { - set_label_active(iter, i + 1); - } - } - button.sensitive = true; - } - - private async void fetch_keys() { - label.set_markup(build_markup_string(_("Loading…"), _("Querying GnuPG"))); - - SourceFunc callback = fetch_keys.callback; - new Thread (null, () => { // Querying GnuPG might take some time - try { - keys = GPGHelper.get_keylist(null, true); - } catch (Error e) { - warning(e.message); - } - Idle.add((owned)callback); - return null; - }); - yield; - } - - private void set_label_active(TreeIter iter, int i = -1) { - Value text; - list_store.get_value(iter, 0, out text); - label.set_markup((string) text); - if (i != -1) combobox.active = i; - } - - private void key_changed() { - TreeIter selected; - bool iter_valid = combobox.get_active_iter(out selected); - if (iter_valid) { - Value key_value; - list_store.get_value(selected, 1, out key_value); - string? key_id = key_value as string; - if (key_id != null) { - if (plugin.modules.has_key(current_account)) { - plugin.modules[current_account].set_private_key_id(key_id); - } - plugin.db.set_account_key(current_account, key_id); - } - set_label_active(selected); - deactivate(); - } - } - - private string build_markup_string(string primary, string secondary) { - return @"$(Markup.escape_text(primary))\n$secondary"; - } - - public override Object? get_widget(WidgetType type) { - if (type != WidgetType.GTK4) return null; - return stack; - } -} -} \ No newline at end of file diff --git a/plugins/openpgp/src/encryption_preferences_entry.vala b/plugins/openpgp/src/encryption_preferences_entry.vala new file mode 100644 index 00000000..4620e173 --- /dev/null +++ b/plugins/openpgp/src/encryption_preferences_entry.vala @@ -0,0 +1,86 @@ +using Adw; +using Dino.Entities; +using Gtk; + +namespace Dino.Plugins.OpenPgp { + + public class PgpPreferencesEntry : Plugins.EncryptionPreferencesEntry { + + private Plugin plugin; + + public PgpPreferencesEntry(Plugin plugin) { + this.plugin = plugin; + } + + public override Object? get_widget(Account account, WidgetType type) { + if (type != WidgetType.GTK4) return null; + StringList string_list = new StringList(null); + string_list.append(_("Querying GnuPG")); + + Adw.PreferencesGroup preferences_group = new Adw.PreferencesGroup() { title="OpenPGP" }; + populate_string_list.begin(account, preferences_group); + + return preferences_group; + } + + public override string id { get { return "pgp_preferences_encryption"; }} + + private async void populate_string_list(Account account, Adw.PreferencesGroup preferences_group) { + var keys = yield get_pgp_keys(); + + if (keys == null) { + preferences_group.add(new Adw.ActionRow() { title="Announce key", subtitle="Error in GnuPG" }); + return; + } + if (keys.size == 0) { + preferences_group.add(new Adw.ActionRow() { title="Announce key", subtitle="No keys available. Generate one!" }); + return; + } + + StringList string_list = new StringList(null); +#if Adw_1_4 + var drop_down = new Adw.ComboRow() { title = "Announce key" }; + drop_down.model = string_list; + preferences_group.add(drop_down); +#else + var view = new Adw.ActionRow() { title = "Announce key" }; + var drop_down = new DropDown(string_list, null) { valign = Align.CENTER }; + view.activatable_widget = drop_down; + view.add_suffix(drop_down); + preferences_group.add(view); +#endif + + string_list.append(_("Disabled")); + for (int i = 0; i < keys.size; i++) { + string_list.append(@"$(keys[i].uids[0].uid)\n$(keys[i].fpr.substring(24, 16))"); + if (keys[i].fpr == plugin.db.get_account_key(account)) { + drop_down.selected = i + 1; + } + } + + drop_down.notify["selected"].connect(() => { + var key_id = drop_down.selected == 0 ? "" : keys[(int)drop_down.selected - 1].fpr; + if (plugin.modules.has_key(account)) { + plugin.modules[account].set_private_key_id(key_id); + } + plugin.db.set_account_key(account, key_id); + }); + } + + private static async Gee.List get_pgp_keys() { + Gee.List keys = null; + SourceFunc callback = get_pgp_keys.callback; + new Thread (null, () => { // Querying GnuPG might take some time + try { + keys = GPGHelper.get_keylist(null, true); + } catch (Error e) { + warning(e.message); + } + Idle.add((owned)callback); + return null; + }); + yield; + return keys; + } + } +} \ No newline at end of file diff --git a/plugins/openpgp/src/plugin.vala b/plugins/openpgp/src/plugin.vala index 324b8652..463058f3 100644 --- a/plugins/openpgp/src/plugin.vala +++ b/plugins/openpgp/src/plugin.vala @@ -13,18 +13,16 @@ public class Plugin : Plugins.RootInterface, Object { public HashMap modules = new HashMap(Account.hash_func, Account.equals_func); private EncryptionListEntry list_entry; - private AccountSettingsEntry settings_entry; private ContactDetailsProvider contact_details_provider; public void registered(Dino.Application app) { this.app = app; this.db = new Database(Path.build_filename(Application.get_storage_dir(), "pgp.db")); this.list_entry = new EncryptionListEntry(app.stream_interactor, db); - this.settings_entry = new AccountSettingsEntry(this); this.contact_details_provider = new ContactDetailsProvider(app.stream_interactor); app.plugin_registry.register_encryption_list_entry(list_entry); - app.plugin_registry.register_account_settings_entry(settings_entry); + app.plugin_registry.register_encryption_preferences_entry(new PgpPreferencesEntry(this)); app.plugin_registry.register_contact_details_entry(contact_details_provider); app.stream_interactor.module_manager.initialize_account_modules.connect(on_initialize_account_modules); -- cgit v1.2.3-70-g09d2 From da4ded964f122ffef194d3f7d7cf7fd0fd71d8cf Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Fri, 28 Jun 2024 15:33:59 +0100 Subject: Self-ping with server-given roomnick (#1594) * Self-ping with server-given roomnick (#1467) XEP-0045 describes how a server can modify a user's roomnick when joining a room. To do this, it assigns status code 210, and sets the "from" attribute to a JID with thew newly assigned nickname when sending the user's presence back to the client. This is used in IRC gateways such as biboumi. Since you can only have one nick per IRC server, if you try and join multiple IRC-bridged MUCs on the same IRC server, it will modify your nick to match the existing one you have on that server. Currently, when Dino performs a self-ping after joining a room, it uses the nick that the client first requested, instead of the nick returned from the server. This can lead to incorrect self-pings being sent out (until the client is restarted, and populates mucs_todo correctly). This happens because it adds the requested Jid to mucs_todo, before getting the given nick from the server. Therefore, this commit adds the jid that has been given from the server instead. If there is any error in requesting to join the MUC, it adds the requested nick, to match existing behaviour and allow the request to be retried via future self-pings. fixes #1467 * Minor coding style changes --------- Co-authored-by: fiaxh --- libdino/src/service/muc_manager.vala | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'libdino') diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 6b52fe36..111ace22 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -84,11 +84,6 @@ public class MucManager : StreamInteractionModule, Object { } mucs_joining[account].add(jid); - if (!mucs_todo.has_key(account)) { - mucs_todo[account] = new HashSet(Jid.hash_bare_func, Jid.equals_bare_func); - } - mucs_todo[account].add(jid.with_resource(nick_)); - Muc.JoinResult? res = yield stream.get_module(Xep.Muc.Module.IDENTITY).enter(stream, jid.bare_jid, nick_, password, history_since, receive_history, null); mucs_joining[account].remove(jid); @@ -127,6 +122,11 @@ public class MucManager : StreamInteractionModule, Object { enter_errors[jid] = res.muc_error; } + if (!mucs_todo.has_key(account)) { + mucs_todo[account] = new HashSet(Jid.hash_bare_func, Jid.equals_bare_func); + } + mucs_todo[account].add(jid.with_resource(res.nick ?? nick_)); + return res; } -- cgit v1.2.3-70-g09d2 From c95b65e5b47f22b1827f351a59cabb8e5f776def Mon Sep 17 00:00:00 2001 From: Marvin W Date: Fri, 19 Jul 2024 18:25:03 +0200 Subject: OMEMO: Do not show message for OMEMO messages without payload --- libdino/src/entity/message.vala | 1 + plugins/omemo/src/logic/decrypt.vala | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) (limited to 'libdino') diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index 912639b1..9d1cd43e 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -202,6 +202,7 @@ public class Message : Object { } public static uint hash_func(Message message) { + if (message.body == null) return 0; return message.body.hash(); } diff --git a/plugins/omemo/src/logic/decrypt.vala b/plugins/omemo/src/logic/decrypt.vala index 561e557b..04339c93 100644 --- a/plugins/omemo/src/logic/decrypt.vala +++ b/plugins/omemo/src/logic/decrypt.vala @@ -28,9 +28,6 @@ namespace Dino.Plugins.Omemo { StanzaNode? encrypted_node = stanza.stanza.get_subnode("encrypted", NS_URI); if (encrypted_node == null || MessageFlag.get_flag(stanza) != null || stanza.from == null) return false; - if (message.body == null && Xep.ExplicitEncryption.get_encryption_tag(stanza) == NS_URI) { - message.body = "[This message is OMEMO encrypted]"; // TODO temporary - } if (!Plugin.ensure_context()) return false; int identity_id = db.identity.get_id(conversation.account.id); @@ -38,7 +35,7 @@ namespace Dino.Plugins.Omemo { stanza.add_flag(flag); Xep.Omemo.ParsedData? data = parse_node(encrypted_node); - if (data == null || data.ciphertext == null) return false; + if (data == null) return false; foreach (Bytes encr_key in data.our_potential_encrypted_keys.keys) { @@ -52,14 +49,16 @@ namespace Dino.Plugins.Omemo { foreach (Jid possible_jid in possible_jids) { try { uint8[] key = decrypt_key(data, possible_jid); - string cleartext = arr_to_str(aes_decrypt(Cipher.AES_GCM_NOPADDING, key, data.iv, data.ciphertext)); + if (data.ciphertext != null) { + string cleartext = arr_to_str(aes_decrypt(Cipher.AES_GCM_NOPADDING, key, data.iv, data.ciphertext)); + message.body = cleartext; + } // If we figured out which real jid a message comes from due to decryption working, save it if (conversation.type_ == Conversation.Type.GROUPCHAT && message.real_jid == null) { message.real_jid = possible_jid; } - message.body = cleartext; message.encryption = Encryption.OMEMO; trust_manager.message_device_id_map[message] = data.sid; @@ -71,7 +70,7 @@ namespace Dino.Plugins.Omemo { } if ( - encrypted_node.get_deep_string_content("payload") != null && // Ratchet forwarding doesn't contain payload and might not include us, which is ok + data.ciphertext != null && // Ratchet forwarding doesn't contain payload and might not include us, which is ok data.our_potential_encrypted_keys.size == 0 && // The message was not encrypted to us stream_interactor.module_manager.get_module(message.account, StreamModule.IDENTITY).store.local_registration_id != data.sid // Message from this device. Never encrypted to itself. ) { -- cgit v1.2.3-70-g09d2 From ceb921a0148f7fdc2a9df3e6b85143bf8c26c341 Mon Sep 17 00:00:00 2001 From: fiaxh Date: Fri, 26 Jul 2024 19:10:35 +0200 Subject: Store reply message as sent, with fallback --- libdino/src/entity/message.vala | 36 ++++++++++++++++++++++- libdino/src/service/fallback_body.vala | 15 ++-------- libdino/src/service/message_correction.vala | 2 +- libdino/src/service/message_processor.vala | 36 +++++++++-------------- libdino/src/service/replies.vala | 15 ++-------- main/src/ui/chat_input/chat_input_controller.vala | 13 +++++++- 6 files changed, 66 insertions(+), 51 deletions(-) (limited to 'libdino') diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index 9d1cd43e..4e6c7f45 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -67,7 +67,7 @@ public class Message : Object { } } public string? edit_to = null; - public int quoted_item_id = 0; + public int quoted_item_id { get; private set; default=0; } private Gee.List fallbacks = null; @@ -142,6 +142,22 @@ public class Message : Object { notify.connect(on_update); } + public void set_quoted_item(int quoted_content_item_id) { + if (id == -1) { + warning("Message needs to be persisted before setting quoted item"); + return; + } + + this.quoted_item_id = quoted_content_item_id; + + db.reply.upsert() + .value(db.reply.message_id, id, true) + .value(db.reply.quoted_content_item_id, quoted_content_item_id) + .value_null(db.reply.quoted_message_stanza_id) + .value_null(db.reply.quoted_message_from) + .perform(); + } + public Gee.List get_fallbacks() { if (fallbacks != null) return fallbacks; @@ -165,7 +181,25 @@ public class Message : Object { } public void set_fallbacks(Gee.List fallbacks) { + if (id == -1) { + warning("Message needs to be persisted before setting fallbacks"); + return; + } + this.fallbacks = fallbacks; + + foreach (var fallback in fallbacks) { + foreach (var location in fallback.locations) { + db.body_meta.insert() + .value(db.body_meta.message_id, id) + .value(db.body_meta.info_type, Xep.FallbackIndication.NS_URI) + .value(db.body_meta.info, fallback.ns_uri) + .value(db.body_meta.from_char, location.from_char) + .value(db.body_meta.to_char, location.to_char) + .perform(); + } + } + } public void set_type_string(string type) { diff --git a/libdino/src/service/fallback_body.vala b/libdino/src/service/fallback_body.vala index 13323427..0ce89ade 100644 --- a/libdino/src/service/fallback_body.vala +++ b/libdino/src/service/fallback_body.vala @@ -46,20 +46,9 @@ public class Dino.FallbackBody : StreamInteractionModule, Object { if (fallbacks.is_empty) return false; foreach (var fallback in fallbacks) { - if (fallback.ns_uri != Xep.Replies.NS_URI) continue; - - foreach (var location in fallback.locations) { - db.body_meta.insert() - .value(db.body_meta.message_id, message.id) - .value(db.body_meta.info_type, Xep.FallbackIndication.NS_URI) - .value(db.body_meta.info, fallback.ns_uri) - .value(db.body_meta.from_char, location.from_char) - .value(db.body_meta.to_char, location.to_char) - .perform(); - } - - message.set_fallbacks(fallbacks); + if (fallback.ns_uri != Xep.Replies.NS_URI) continue; // TODO what if it's not } + message.set_fallbacks(fallbacks); return false; } diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala index 8f9770d8..6d4137d4 100644 --- a/libdino/src/service/message_correction.vala +++ b/libdino/src/service/message_correction.vala @@ -44,7 +44,7 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(correction_text, conversation); out_message.edit_to = stanza_id; - out_message.quoted_item_id = old_message.quoted_item_id; + out_message.set_quoted_item(old_message.quoted_item_id); outstanding_correction_nodes[out_message.stanza_id] = stanza_id; stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index baab37ce..620c93eb 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -406,8 +406,20 @@ public class MessageProcessor : StreamInteractionModule, Object { new_message.type_ = MessageStanza.TYPE_CHAT; } - string? fallback = get_fallback_body_set_infos(message, new_message, conversation); - new_message.body = fallback == null ? message.body : fallback + message.body; + if (message.quoted_item_id != 0) { + ContentItem? quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, message.quoted_item_id); + if (quoted_content_item != null) { + Jid? quoted_sender = message.from; + string? quoted_stanza_id = stream_interactor.get_module(ContentItemStore.IDENTITY).get_message_id_for_content_item(conversation, quoted_content_item); + if (quoted_sender != null && quoted_stanza_id != null) { + Xep.Replies.set_reply_to(new_message, new Xep.Replies.ReplyTo(quoted_sender, quoted_stanza_id)); + } + + foreach (var fallback in message.get_fallbacks()) { + Xep.FallbackIndication.set_fallback(new_message, fallback); + } + } + } build_message_stanza(message, new_message, conversation); pre_message_send(message, new_message, conversation); @@ -456,26 +468,6 @@ public class MessageProcessor : StreamInteractionModule, Object { } }); } - - public string? get_fallback_body_set_infos(Entities.Message message, MessageStanza new_stanza, Conversation conversation) { - if (message.quoted_item_id == 0) return null; - - ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, message.quoted_item_id); - if (content_item == null) return null; - - Jid? quoted_sender = stream_interactor.get_module(ContentItemStore.IDENTITY).get_message_sender_for_content_item(conversation, content_item); - string? quoted_stanza_id = stream_interactor.get_module(ContentItemStore.IDENTITY).get_message_id_for_content_item(conversation, content_item); - if (quoted_sender != null && quoted_stanza_id != null) { - Xep.Replies.set_reply_to(new_stanza, new Xep.Replies.ReplyTo(quoted_sender, quoted_stanza_id)); - } - - string fallback = FallbackBody.get_quoted_fallback_body(content_item); - - var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback.char_count()); - Xep.FallbackIndication.set_fallback(new_stanza, new Xep.FallbackIndication.Fallback(Xep.Replies.NS_URI, new Xep.FallbackIndication.FallbackLocation[] { fallback_location })); - - return fallback; - } } public abstract class MessageListener : Xmpp.OrderedListener { diff --git a/libdino/src/service/replies.vala b/libdino/src/service/replies.vala index 58d44b37..cc9f43cc 100644 --- a/libdino/src/service/replies.vala +++ b/libdino/src/service/replies.vala @@ -38,17 +38,6 @@ public class Dino.Replies : StreamInteractionModule, Object { return null; } - public void set_message_is_reply_to(Message message, ContentItem reply_to) { - message.quoted_item_id = reply_to.id; - - db.reply.upsert() - .value(db.reply.message_id, message.id, true) - .value(db.reply.quoted_content_item_id, reply_to.id) - .value_null(db.reply.quoted_message_stanza_id) - .value_null(db.reply.quoted_message_from) - .perform(); - } - private void on_incoming_message(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { // Check if a previous message was in reply to this one var reply_qry = db.reply.select(); @@ -67,7 +56,7 @@ public class Dino.Replies : StreamInteractionModule, Object { ContentItem? message_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_foreign(conversation, 1, message.id); Message? reply_message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(reply_row[db.message.id], conversation); if (message_item != null && reply_message != null) { - set_message_is_reply_to(reply_message, message_item); + reply_message.set_quoted_item(message_item.id); } } @@ -78,7 +67,7 @@ public class Dino.Replies : StreamInteractionModule, Object { ContentItem? quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_content_item_for_message_id(conversation, reply_to.to_message_id); if (quoted_content_item == null) return; - set_message_is_reply_to(message, quoted_content_item); + message.set_quoted_item(quoted_content_item.id); } private class ReceivedMessageListener : MessageListener { diff --git a/main/src/ui/chat_input/chat_input_controller.vala b/main/src/ui/chat_input/chat_input_controller.vala index d1c42d35..cf8e5a02 100644 --- a/main/src/ui/chat_input/chat_input_controller.vala +++ b/main/src/ui/chat_input/chat_input_controller.vala @@ -1,6 +1,7 @@ using Gee; using Gdk; using Gtk; +using Xmpp; using Dino.Entities; @@ -195,7 +196,17 @@ public class ChatInputController : Object { } Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(text, conversation); if (quoted_content_item_bak != null) { - stream_interactor.get_module(Replies.IDENTITY).set_message_is_reply_to(out_message, quoted_content_item_bak); + out_message.set_quoted_item(quoted_content_item_bak.id); + + // Store body with fallback + string fallback = FallbackBody.get_quoted_fallback_body(quoted_content_item_bak); + out_message.body = fallback + out_message.body; + + // Store fallback location + var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback.char_count()); + var fallback_list = new ArrayList(); + fallback_list.add(new Xep.FallbackIndication.Fallback(Xep.Replies.NS_URI, new Xep.FallbackIndication.FallbackLocation[] { fallback_location })); + out_message.set_fallbacks(fallback_list); } stream_interactor.get_module(MessageProcessor.IDENTITY).send_message(out_message, conversation); } -- cgit v1.2.3-70-g09d2 From b0ff90a14a5d127e17f2371f87e7bb659de3a68f Mon Sep 17 00:00:00 2001 From: fiaxh Date: Mon, 29 Jul 2024 13:16:54 +0200 Subject: Add initial message markup (XEP-0394) support --- libdino/CMakeLists.txt | 1 + libdino/meson.build | 1 + libdino/src/entity/message.vala | 52 +++++++-- libdino/src/service/message_correction.vala | 28 ++--- libdino/src/service/message_processor.vala | 34 ++++-- libdino/src/util/send_message.vala | 56 +++++++++ main/src/ui/chat_input/chat_input_controller.vala | 19 +-- main/src/ui/chat_input/chat_text_view.vala | 130 +++++++++++++++++++++ .../conversation_content_view/message_widget.vala | 105 ++++++++++++----- main/src/ui/util/helper.vala | 25 ++++ xmpp-vala/CMakeLists.txt | 1 + xmpp-vala/meson.build | 1 + xmpp-vala/src/module/xep/0394_message_markup.vala | 81 +++++++++++++ 13 files changed, 456 insertions(+), 78 deletions(-) create mode 100644 libdino/src/util/send_message.vala create mode 100644 xmpp-vala/src/module/xep/0394_message_markup.vala (limited to 'libdino') diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index e4d786c9..34cf9575 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -68,6 +68,7 @@ SOURCES src/service/util.vala src/util/display_name.vala + src/util/send_message.vala src/util/util.vala src/util/weak_map.vala src/util/weak_timeout.vala diff --git a/libdino/meson.build b/libdino/meson.build index 17804d23..559a81b5 100644 --- a/libdino/meson.build +++ b/libdino/meson.build @@ -74,6 +74,7 @@ sources = files( 'src/service/stream_interactor.vala', 'src/service/util.vala', 'src/util/display_name.vala', + 'src/util/send_message.vala', 'src/util/util.vala', 'src/util/weak_map.vala', 'src/util/weak_timeout.vala', diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index 4e6c7f45..e5aad25f 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -70,6 +70,7 @@ public class Message : Object { public int quoted_item_id { get; private set; default=0; } private Gee.List fallbacks = null; + private Gee.List markups = null; private Database? db; @@ -160,16 +161,53 @@ public class Message : Object { public Gee.List get_fallbacks() { if (fallbacks != null) return fallbacks; + fetch_body_meta(); + return fallbacks; + } + + public Gee.List get_markups() { + if (markups != null) return markups; + fetch_body_meta(); + + return markups; + } + + public void persist_markups(Gee.List markups, int message_id) { + this.markups = markups; + + foreach (var span in markups) { + foreach (var ty in span.types) { + db.body_meta.insert() + .value(db.body_meta.info_type, Xep.MessageMarkup.NS_URI) + .value(db.body_meta.message_id, message_id) + .value(db.body_meta.info, Xep.MessageMarkup.span_type_to_str(ty)) + .value(db.body_meta.from_char, span.start_char) + .value(db.body_meta.to_char, span.end_char) + .perform(); + } + } + } + + private void fetch_body_meta() { var fallbacks_by_ns = new HashMap>(); - foreach (Qlite.Row row in db.body_meta.select().with(db.body_meta.message_id, "=", id)) { - if (row[db.body_meta.info_type] != Xep.FallbackIndication.NS_URI) continue; + var markups = new ArrayList(); - string ns_uri = row[db.body_meta.info]; - if (!fallbacks_by_ns.has_key(ns_uri)) { - fallbacks_by_ns[ns_uri] = new ArrayList(); + foreach (Qlite.Row row in db.body_meta.select().with(db.body_meta.message_id, "=", id)) { + switch (row[db.body_meta.info_type]) { + case Xep.FallbackIndication.NS_URI: + string ns_uri = row[db.body_meta.info]; + if (!fallbacks_by_ns.has_key(ns_uri)) { + fallbacks_by_ns[ns_uri] = new ArrayList(); + } + fallbacks_by_ns[ns_uri].add(new Xep.FallbackIndication.FallbackLocation(row[db.body_meta.from_char], row[db.body_meta.to_char])); + break; + case Xep.MessageMarkup.NS_URI: + var types = new ArrayList(); + types.add(Xep.MessageMarkup.str_to_span_type(row[db.body_meta.info])); + markups.add(new Xep.MessageMarkup.Span() { types=types, start_char=row[db.body_meta.from_char], end_char=row[db.body_meta.to_char] }); + break; } - fallbacks_by_ns[ns_uri].add(new Xep.FallbackIndication.FallbackLocation(row[db.body_meta.from_char], row[db.body_meta.to_char])); } var fallbacks = new ArrayList(); @@ -177,7 +215,7 @@ public class Message : Object { fallbacks.add(new Xep.FallbackIndication.Fallback(ns_uri, fallbacks_by_ns[ns_uri].to_array())); } this.fallbacks = fallbacks; - return fallbacks; + this.markups = markups; } public void set_fallbacks(Gee.List fallbacks) { diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala index 6d4137d4..e6401a05 100644 --- a/libdino/src/service/message_correction.vala +++ b/libdino/src/service/message_correction.vala @@ -39,27 +39,21 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { }); } - public void send_correction(Conversation conversation, Message old_message, string correction_text) { - string stanza_id = old_message.edit_to ?? old_message.stanza_id; + public void set_correction(Conversation conversation, Message message, Message old_message) { + string reference_stanza_id = old_message.edit_to ?? old_message.stanza_id; - Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(correction_text, conversation); - out_message.edit_to = stanza_id; - out_message.set_quoted_item(old_message.quoted_item_id); - outstanding_correction_nodes[out_message.stanza_id] = stanza_id; - stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); + outstanding_correction_nodes[message.stanza_id] = reference_stanza_id; db.message_correction.insert() - .value(db.message_correction.message_id, out_message.id) - .value(db.message_correction.to_stanza_id, stanza_id) - .perform(); + .value(db.message_correction.message_id, message.id) + .value(db.message_correction.to_stanza_id, reference_stanza_id) + .perform(); db.content_item.update() - .with(db.content_item.foreign_id, "=", old_message.id) - .with(db.content_item.content_type, "=", 1) - .set(db.content_item.foreign_id, out_message.id) - .perform(); - - on_received_correction(conversation, out_message.id); + .with(db.content_item.foreign_id, "=", old_message.id) + .with(db.content_item.content_type, "=", 1) + .set(db.content_item.foreign_id, message.id) + .perform(); } public bool is_own_correction_allowed(Conversation conversation, Message message) { @@ -145,7 +139,7 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { return false; } - private void on_received_correction(Conversation conversation, int message_id) { + public void on_received_correction(Conversation conversation, int message_id) { ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_foreign(conversation, 1, message_id); if (content_item != null) { received_correction(content_item); diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index 620c93eb..d8ea3e2d 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -38,6 +38,7 @@ public class MessageProcessor : StreamInteractionModule, Object { received_pipeline.connect(new FilterMessageListener()); received_pipeline.connect(new StoreMessageListener(this, stream_interactor)); received_pipeline.connect(new StoreContentItemListener(stream_interactor)); + received_pipeline.connect(new MarkupListener(stream_interactor)); stream_interactor.account_added.connect(on_account_added); @@ -45,18 +46,6 @@ public class MessageProcessor : StreamInteractionModule, Object { stream_interactor.stream_resumed.connect(send_unsent_chat_messages); } - public Entities.Message send_text(string text, Conversation conversation) { - Entities.Message message = create_out_message(text, conversation); - return send_message(message, conversation); - } - - public Entities.Message send_message(Entities.Message message, Conversation conversation) { - stream_interactor.get_module(ContentItemStore.IDENTITY).insert_message(message, conversation); - send_xmpp_message(message, conversation); - message_sent(message, conversation); - return message; - } - private void convert_sending_to_unsent_msgs(Account account) { db.message.update() .with(db.message.account_id, "=", account.id) @@ -344,6 +333,25 @@ public class MessageProcessor : StreamInteractionModule, Object { } } + private class MarkupListener : MessageListener { + + public string[] after_actions_const = new string[]{ "STORE" }; + public override string action_group { get { return "Markup"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + + public MarkupListener(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + Gee.List markups = MessageMarkup.get_spans(stanza); + message.persist_markups(markups, message.id); + return false; + } + } + private class StoreContentItemListener : MessageListener { public string[] after_actions_const = new string[]{ "DEDUPLICATE", "DECRYPT", "FILTER_EMPTY", "STORE", "CORRECTION", "MESSAGE_REINTERPRETING" }; @@ -421,6 +429,8 @@ public class MessageProcessor : StreamInteractionModule, Object { } } + MessageMarkup.add_spans(new_message, message.get_markups()); + build_message_stanza(message, new_message, conversation); pre_message_send(message, new_message, conversation); if (message.marked == Entities.Message.Marked.UNSENT || message.marked == Entities.Message.Marked.WONTSEND) return; diff --git a/libdino/src/util/send_message.vala b/libdino/src/util/send_message.vala new file mode 100644 index 00000000..234a1644 --- /dev/null +++ b/libdino/src/util/send_message.vala @@ -0,0 +1,56 @@ +using Gee; + +using Dino.Entities; +using Xmpp; + +namespace Dino { + + public void send_message(Conversation conversation, string text, int reply_to_id, Message? correction_to, Gee.List markups) { + StreamInteractor stream_interactor = Application.get_default().stream_interactor; + + Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(text, conversation); + + if (correction_to != null) { + string correction_to_stanza_id = correction_to.edit_to ?? correction_to.stanza_id; + out_message.edit_to = correction_to_stanza_id; + stream_interactor.get_module(MessageCorrection.IDENTITY).set_correction(conversation, out_message, correction_to); + } + + if (reply_to_id != 0) { + ContentItem reply_to = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(conversation, reply_to_id); + + out_message.set_quoted_item(reply_to.id); + + // Store body with fallback + string fallback = FallbackBody.get_quoted_fallback_body(reply_to); + out_message.body = fallback + out_message.body; + + // Store fallback location + var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback.char_count()); + var fallback_list = new ArrayList(); + fallback_list.add(new Xep.FallbackIndication.Fallback(Xep.Replies.NS_URI, new Xep.FallbackIndication.FallbackLocation[] { fallback_location })); + out_message.set_fallbacks(fallback_list); + + // Adjust markups to new prefix + foreach (var span in markups) { + span.start_char += fallback.length; + span.end_char += fallback.length; + } + } + + if (!markups.is_empty) { + out_message.persist_markups(markups, out_message.id); + } + + + if (correction_to != null) { + stream_interactor.get_module(MessageCorrection.IDENTITY).on_received_correction(conversation, out_message.id); + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); + return; + } + + stream_interactor.get_module(ContentItemStore.IDENTITY).insert_message(out_message, conversation); + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); + stream_interactor.get_module(MessageProcessor.IDENTITY).message_sent(out_message, conversation); + } +} \ No newline at end of file diff --git a/main/src/ui/chat_input/chat_input_controller.vala b/main/src/ui/chat_input/chat_input_controller.vala index cf8e5a02..07499aa4 100644 --- a/main/src/ui/chat_input/chat_input_controller.vala +++ b/main/src/ui/chat_input/chat_input_controller.vala @@ -3,6 +3,7 @@ using Gdk; using Gtk; using Xmpp; +using Xmpp; using Dino.Entities; namespace Dino.Ui { @@ -136,6 +137,7 @@ public class ChatInputController : Object { string text = chat_input.chat_text_view.text_view.buffer.text; ContentItem? quoted_content_item_bak = quoted_content_item; + var markups = chat_input.chat_text_view.get_markups(); // Reset input state. Has do be done before parsing commands, because those directly return. chat_input.chat_text_view.text_view.buffer.text = ""; @@ -194,21 +196,8 @@ public class ChatInputController : Object { break; } } - Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(text, conversation); - if (quoted_content_item_bak != null) { - out_message.set_quoted_item(quoted_content_item_bak.id); - - // Store body with fallback - string fallback = FallbackBody.get_quoted_fallback_body(quoted_content_item_bak); - out_message.body = fallback + out_message.body; - - // Store fallback location - var fallback_location = new Xep.FallbackIndication.FallbackLocation(0, (int)fallback.char_count()); - var fallback_list = new ArrayList(); - fallback_list.add(new Xep.FallbackIndication.Fallback(Xep.Replies.NS_URI, new Xep.FallbackIndication.FallbackLocation[] { fallback_location })); - out_message.set_fallbacks(fallback_list); - } - stream_interactor.get_module(MessageProcessor.IDENTITY).send_message(out_message, conversation); + + Dino.send_message(conversation, text, quoted_content_item_bak != null ? quoted_content_item_bak.id : 0, null, markups); } private void on_text_input_changed() { diff --git a/main/src/ui/chat_input/chat_text_view.vala b/main/src/ui/chat_input/chat_text_view.vala index aa246d8d..c7429318 100644 --- a/main/src/ui/chat_input/chat_text_view.vala +++ b/main/src/ui/chat_input/chat_text_view.vala @@ -40,6 +40,10 @@ public class ChatTextView : Box { private uint wait_queue_resize; private SmileyConverter smiley_converter; + private TextTag italic_tag; + private TextTag bold_tag; + private TextTag strikethrough_tag; + construct { valign = Align.CENTER; scrolled_window.set_child(text_view); @@ -49,6 +53,15 @@ public class ChatTextView : Box { text_input_key_events.key_pressed.connect(on_text_input_key_press); text_view.add_controller(text_input_key_events); + italic_tag = text_view.buffer.create_tag("italic"); + italic_tag.style = Pango.Style.ITALIC; + + bold_tag = text_view.buffer.create_tag("bold"); + bold_tag.weight = Pango.Weight.BOLD; + + strikethrough_tag = text_view.buffer.create_tag("strikethrough"); + strikethrough_tag.strikethrough = true; + smiley_converter = new SmileyConverter(text_view); scrolled_window.vadjustment.changed.connect(on_upper_notify); @@ -60,6 +73,37 @@ public class ChatTextView : Box { }); } + public void set_text(Message message) { + // Get a copy of the markup spans, such that we can modify them + var markups = new ArrayList(); + foreach (var markup in message.get_markups()) { + markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char }); + } + + text_view.buffer.text = Util.remove_fallbacks_adjust_markups(message.body, message.quoted_item_id > 0, message.get_fallbacks(), markups); + + foreach (var markup in markups) { + foreach (var ty in markup.types) { + TextTag tag = null; + switch (ty) { + case Xep.MessageMarkup.SpanType.EMPHASIS: + tag = italic_tag; + break; + case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS: + tag = bold_tag; + break; + case Xep.MessageMarkup.SpanType.DELETED: + tag = strikethrough_tag; + break; + } + TextIter start_selection, end_selection; + text_view.buffer.get_iter_at_offset(out start_selection, markup.start_char); + text_view.buffer.get_iter_at_offset(out end_selection, markup.end_char); + text_view.buffer.apply_tag(tag, start_selection, end_selection); + } + } + } + public override void dispose() { base.dispose(); if (wait_queue_resize != 0) { @@ -95,6 +139,7 @@ public class ChatTextView : Box { } private bool on_text_input_key_press(EventControllerKey controller, uint keyval, uint keycode, Gdk.ModifierType state) { + // Enter pressed -> Send message (except if it was Shift+Enter) if (keyval in new uint[]{ Key.Return, Key.KP_Enter }) { // Allow the text view to process the event. Needed for IME. if (text_view.im_context_filter_keypress(controller.get_current_event())) { @@ -109,11 +154,96 @@ public class ChatTextView : Box { } return true; } + if (keyval == Key.Escape) { cancel_input(); } + + // Style text section bold (CTRL + b) or italic (CTRL + i) + if ((state & ModifierType.CONTROL_MASK) > 0) { + if (keyval in new uint[]{ Key.i, Key.b }) { + TextIter start_selection, end_selection; + text_view.buffer.get_selection_bounds(out start_selection, out end_selection); + + TextTag tag = null; + bool already_formatted = false; + var markup_types = get_markup_types_from_iter(start_selection); + if (keyval == Key.i) { + tag = italic_tag; + already_formatted = markup_types.contains(Xep.MessageMarkup.SpanType.EMPHASIS); + } else if (keyval == Key.b) { + tag = bold_tag; + already_formatted = markup_types.contains(Xep.MessageMarkup.SpanType.STRONG_EMPHASIS); + } else if (keyval == Key.s) { + tag = strikethrough_tag; + already_formatted = markup_types.contains(Xep.MessageMarkup.SpanType.DELETED); + } + if (tag != null) { + if (already_formatted) { + text_view.buffer.remove_tag(tag, start_selection, end_selection); + } else { + text_view.buffer.apply_tag(tag, start_selection, end_selection); + } + } + } + } + return false; } + + public Gee.List get_markups() { + var markups = new HashMap(); + markups[Xep.MessageMarkup.SpanType.EMPHASIS] = Xep.MessageMarkup.SpanType.EMPHASIS; + markups[Xep.MessageMarkup.SpanType.STRONG_EMPHASIS] = Xep.MessageMarkup.SpanType.STRONG_EMPHASIS; + markups[Xep.MessageMarkup.SpanType.DELETED] = Xep.MessageMarkup.SpanType.DELETED; + + var ended_groups = new ArrayList(); + Xep.MessageMarkup.Span current_span = null; + + TextIter iter; + text_view.buffer.get_start_iter(out iter); + int i = 0; + do { + var char_markups = get_markup_types_from_iter(iter); + + // Not the same set of markups as last character -> end all spans + if (current_span != null && (!char_markups.contains_all(current_span.types) || !current_span.types.contains_all(char_markups))) { + ended_groups.add(current_span); + current_span = null; + } + + if (char_markups.size > 0) { + if (current_span == null) { + current_span = new Xep.MessageMarkup.Span() { types=char_markups, start_char=i, end_char=i + 1 }; + } else { + current_span.end_char = i + 1; + } + } + + i++; + } while (iter.forward_char()); + + if (current_span != null) { + ended_groups.add(current_span); + } + + return ended_groups; + } + + private Gee.List get_markup_types_from_iter(TextIter iter) { + var ret = new ArrayList(); + + foreach (TextTag tag in iter.get_tags()) { + if (tag.style == Pango.Style.ITALIC) { + ret.add(Xep.MessageMarkup.SpanType.EMPHASIS); + } else if (tag.weight == Pango.Weight.BOLD) { + ret.add(Xep.MessageMarkup.SpanType.STRONG_EMPHASIS); + } else if (tag.strikethrough) { + ret.add(Xep.MessageMarkup.SpanType.DELETED); + } + } + return ret; + } } } diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 11b38286..376ef4bd 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -26,8 +26,8 @@ public class MessageMetaItem : ContentMetaItem { AdditionalInfo additional_info = AdditionalInfo.NONE; ulong realize_id = -1; - ulong style_updated_id = -1; ulong marked_notify_handler_id = -1; + uint pending_timeout_id = -1; public Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, hexpand=true, vexpand=true }; @@ -64,6 +64,7 @@ public class MessageMetaItem : ContentMetaItem { if (message.marked in Message.MARKED_RECEIVED) { binding.unbind(); this.disconnect(marked_notify_handler_id); + marked_notify_handler_id = -1; } }); } @@ -71,20 +72,72 @@ public class MessageMetaItem : ContentMetaItem { update_label(); } - private string generate_markup_text(ContentItem item) { + private void generate_markup_text(ContentItem item, Label label) { MessageItem message_item = item as MessageItem; Conversation conversation = message_item.conversation; Message message = message_item.message; - bool theme_dependent = false; + // Get a copy of the markup spans, such that we can modify them + var markups = new ArrayList(); + foreach (var markup in message.get_markups()) { + markups.add(new Xep.MessageMarkup.Span() { types=markup.types, start_char=markup.start_char, end_char=markup.end_char }); + } + + string markup_text = message.body; + + var attrs = new AttrList(); + label.set_attributes(attrs); - string markup_text = Dino.message_body_without_reply_fallback(message); + if (markup_text == null) return; // TODO remove + // Only process messages up to a certain size if (markup_text.length > 10000) { markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; } - if (message.body.has_prefix("/me ")) { - markup_text = markup_text.substring(4); + + bool theme_dependent = false; + + markup_text = Util.remove_fallbacks_adjust_markups(markup_text, message.quoted_item_id > 0, message.get_fallbacks(), markups); + + var bold_attr = Pango.attr_weight_new(Pango.Weight.BOLD); + var italic_attr = Pango.attr_style_new(Pango.Style.ITALIC); + var strikethrough_attr = Pango.attr_strikethrough_new(true); + + // Prefix message with name instead of /me + if (markup_text.has_prefix("/me ")) { + string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from); + markup_text = display_name + " " + markup_text.substring(4); + + foreach (Xep.MessageMarkup.Span span in markups) { + int length = display_name.char_count() - 4 + 1; + span.start_char += length; + span.end_char += length; + } + + bold_attr.end_index = display_name.length; + italic_attr.end_index = display_name.length; + attrs.insert(bold_attr.copy()); + attrs.insert(italic_attr.copy()); + } + + foreach (var markup in markups) { + foreach (var ty in markup.types) { + Attribute attr = null; + switch (ty) { + case Xep.MessageMarkup.SpanType.EMPHASIS: + attr = Pango.attr_style_new(Pango.Style.ITALIC); + break; + case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS: + attr = Pango.attr_weight_new(Pango.Weight.BOLD); + break; + case Xep.MessageMarkup.SpanType.DELETED: + attr = Pango.attr_strikethrough_new(true); + break; + } + attr.start_index = markup_text.index_of_nth_char(markup.start_char); + attr.end_index = markup_text.index_of_nth_char(markup.end_char); + attrs.insert(attr.copy()); + } } if (conversation.type_ == Conversation.Type.GROUPCHAT) { @@ -93,11 +146,6 @@ public class MessageMetaItem : ContentMetaItem { markup_text = Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent); } - if (message.body.has_prefix("/me ")) { - string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from); - markup_text = @"$(Markup.escape_text(display_name)) " + markup_text + ""; - } - int only_emoji_count = Util.get_only_emoji_count(markup_text); if (only_emoji_count != -1) { string size_str = only_emoji_count < 5 ? "xx-large" : "large"; @@ -121,8 +169,10 @@ public class MessageMetaItem : ContentMetaItem { additional_info = AdditionalInfo.PENDING; } else { int time_diff = (- (int) message.time.difference(new DateTime.now_utc()) / 1000); - Timeout.add(10000 - time_diff, () => { + if (pending_timeout_id != -1) Source.remove(pending_timeout_id); + pending_timeout_id = Timeout.add(10000 - time_diff, () => { update_label(); + pending_timeout_id = -1; return false; }); } @@ -136,16 +186,14 @@ public class MessageMetaItem : ContentMetaItem { if (theme_dependent && realize_id == -1) { realize_id = label.realize.connect(update_label); -// style_updated_id = label.style_updated.connect(update_label); } else if (!theme_dependent && realize_id != -1) { label.disconnect(realize_id); - label.disconnect(style_updated_id); } - return markup_text; + label.label = markup_text; } public void update_label() { - label.label = generate_markup_text(content_item); + generate_markup_text(content_item, label); } public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) { @@ -209,16 +257,15 @@ public class MessageMetaItem : ContentMetaItem { outer.set_widget(label, Plugins.WidgetType.GTK4, 2); }); edit_mode.send.connect(() => { - if (((MessageItem) content_item).message.body != edit_mode.chat_text_view.text_view.buffer.text) { - on_edit_send(edit_mode.chat_text_view.text_view.buffer.text); - } else { -// edit_cancelled(); - } + string text = edit_mode.chat_text_view.text_view.buffer.text; + var markups = edit_mode.chat_text_view.get_markups(); + Dino.send_message(message_item.conversation, text, message_item.message.quoted_item_id, message_item.message, markups); + in_edit_mode = false; outer.set_widget(label, Plugins.WidgetType.GTK4, 2); }); - edit_mode.chat_text_view.text_view.buffer.text = message.body; + edit_mode.chat_text_view.set_text(message); outer.set_widget(edit_mode, Plugins.WidgetType.GTK4, 2); edit_mode.chat_text_view.text_view.grab_focus(); @@ -227,11 +274,6 @@ public class MessageMetaItem : ContentMetaItem { } } - private void on_edit_send(string text) { - stream_interactor.get_module(MessageCorrection.IDENTITY).send_correction(message_item.conversation, message_item.message, text); - this.in_edit_mode = false; - } - private void on_received_correction(ContentItem content_item) { if (this.content_item.id == content_item.id) { this.content_item = content_item; @@ -251,6 +293,15 @@ public class MessageMetaItem : ContentMetaItem { public override void dispose() { stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.disconnect(on_received_correction); this.notify["in-edit-mode"].disconnect(on_in_edit_mode_changed); + if (marked_notify_handler_id != -1) { + this.disconnect(marked_notify_handler_id); + } + if (realize_id != -1) { + label.disconnect(realize_id); + } + if (pending_timeout_id != -1) { + Source.remove(pending_timeout_id); + } if (label != null) { label.unparent(); label.dispose(); diff --git a/main/src/ui/util/helper.vala b/main/src/ui/util/helper.vala index 63288fc2..45b96b94 100644 --- a/main/src/ui/util/helper.vala +++ b/main/src/ui/util/helper.vala @@ -297,6 +297,31 @@ public static string parse_add_markup_theme(string s_, string? highlight_word, b return s; } + // Modifies `markups`. + public string remove_fallbacks_adjust_markups(string text, bool contains_quote, Gee.List fallbacks, Gee.List markups) { + string processed_text = text; + + foreach (var fallback in fallbacks) { + if (fallback.ns_uri == Xep.Replies.NS_URI && contains_quote) { + foreach (var fallback_location in fallback.locations) { + processed_text = processed_text[0:processed_text.index_of_nth_char(fallback_location.from_char)] + + processed_text[processed_text.index_of_nth_char(fallback_location.to_char):processed_text.length]; + + int length = fallback_location.to_char - fallback_location.from_char; + foreach (Xep.MessageMarkup.Span span in markups) { + if (span.start_char > fallback_location.to_char) { + span.start_char -= length; + } + if (span.end_char > fallback_location.to_char) { + span.end_char -= length; + } + } + } + } + } + return processed_text; + } + /** * This is a heuristic to count emojis in a string {@link http://example.com/} * diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index cfbc0aaf..fa0b08ef 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -55,6 +55,7 @@ SOURCES "src/module/xep/0048_bookmarks.vala" "src/module/xep/0048_conference.vala" + "src/module/xep/0394_message_markup.vala" "src/module/xep/0402_bookmarks2.vala" "src/module/xep/0004_data_forms.vala" diff --git a/xmpp-vala/meson.build b/xmpp-vala/meson.build index be5e96a8..7d062db3 100644 --- a/xmpp-vala/meson.build +++ b/xmpp-vala/meson.build @@ -55,6 +55,7 @@ sources = files( 'src/module/xep/0047_in_band_bytestreams.vala', 'src/module/xep/0048_bookmarks.vala', 'src/module/xep/0048_conference.vala', + 'src/module/xep/0394_message_markup.vala', 'src/module/xep/0049_private_xml_storage.vala', 'src/module/xep/0054_vcard/module.vala', 'src/module/xep/0059_result_set_management.vala', diff --git a/xmpp-vala/src/module/xep/0394_message_markup.vala b/xmpp-vala/src/module/xep/0394_message_markup.vala new file mode 100644 index 00000000..32b441af --- /dev/null +++ b/xmpp-vala/src/module/xep/0394_message_markup.vala @@ -0,0 +1,81 @@ +using Gee; + +namespace Xmpp.Xep.MessageMarkup { + + public const string NS_URI = "urn:xmpp:markup:0"; + + public enum SpanType { + EMPHASIS, + STRONG_EMPHASIS, + DELETED, + } + + public class Span : Object { + public Gee.List types { get; set; } + public int start_char { get; set; } + public int end_char { get; set; } + } + + public Gee.List get_spans(MessageStanza stanza) { + var ret = new ArrayList(); + + foreach (StanzaNode span_node in stanza.stanza.get_deep_subnodes(NS_URI + ":markup", NS_URI + ":span")) { + int start_char = span_node.get_attribute_int("start", -1, NS_URI); + int end_char = span_node.get_attribute_int("end", -1, NS_URI); + if (start_char == -1 || end_char == -1) continue; + + var types = new ArrayList(); + foreach (StanzaNode span_subnode in span_node.get_all_subnodes()) { + types.add(str_to_span_type(span_subnode.name)); + } + ret.add(new Span() { types=types, start_char=start_char, end_char=end_char }); + } + return ret; + } + + public void add_spans(MessageStanza stanza, Gee.List spans) { + if (spans.is_empty) return; + + StanzaNode markup_node = new StanzaNode.build("markup", NS_URI).add_self_xmlns(); + + foreach (var span in spans) { + StanzaNode span_node = new StanzaNode.build("span", NS_URI) + .put_attribute("start", span.start_char.to_string(), NS_URI) + .put_attribute("end", span.end_char.to_string(), NS_URI); + + foreach (var type in span.types) { + span_node.put_node(new StanzaNode.build(span_type_to_str(type), NS_URI)); + } + markup_node.put_node(span_node); + } + + stanza.stanza.put_node(markup_node); + } + + public static string span_type_to_str(Xep.MessageMarkup.SpanType span_type) { + switch (span_type) { + case Xep.MessageMarkup.SpanType.EMPHASIS: + return "emphasis"; + case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS: + return "strong"; + case Xep.MessageMarkup.SpanType.DELETED: + return "deleted"; + default: + return ""; + } + } + + public static Xep.MessageMarkup.SpanType str_to_span_type(string span_str) { + switch (span_str) { + case "emphasis": + return Xep.MessageMarkup.SpanType.EMPHASIS; + case "strong": + return Xep.MessageMarkup.SpanType.STRONG_EMPHASIS; + case "deleted": + return Xep.MessageMarkup.SpanType.DELETED; + default: + return Xep.MessageMarkup.SpanType.EMPHASIS; + } + } + +} \ No newline at end of file -- cgit v1.2.3-70-g09d2