From 64757cbe2572259ad201f4c691aeb545088ae42a Mon Sep 17 00:00:00 2001 From: Marvin W Date: Sat, 14 Mar 2026 13:42:58 +0800 Subject: Add implementation for XEP-0202 Entity Time --- dino.doap | 8 ++ dino.doap.in | 8 ++ libdino/src/application.vala | 2 +- libdino/src/entity/settings.vala | 13 +++ libdino/src/service/entity_info.vala | 63 +++++++++++++- libdino/src/service/module_manager.vala | 1 + main/data/chat_input.ui | 21 +++++ main/data/gresource.xml | 1 + .../scalable/status/dino-clock-alt-symbolic.svg | 2 + .../preferences_window/general_preferences_page.ui | 7 ++ main/data/style.css | 9 ++ main/src/ui/chat_input/view.vala | 97 ++++++++++++++++++++++ main/src/ui/conversation_details.vala | 66 +++++++++++---- main/src/ui/util/preference_group.vala | 65 +++++++-------- main/src/view_model/conversation_details.vala | 1 + main/src/view_model/preferences_dialog.vala | 1 + main/src/view_model/preferences_row.vala | 1 + .../general_preferences_page.vala | 3 + xmpp-vala/meson.build | 1 + xmpp-vala/src/module/iq/module.vala | 13 ++- xmpp-vala/src/module/stanza_error.vala | 3 + xmpp-vala/src/module/xep/0045_muc/module.vala | 2 +- .../src/module/xep/0082_date_time_profiles.vala | 33 +++++++- xmpp-vala/src/module/xep/0202_entity_time.vala | 75 +++++++++++++++++ .../src/module/xep/0203_delayed_delivery.vala | 4 +- .../xep/0215_external_service_discovery.vala | 2 +- .../xep/0313_2_message_archive_management.vala | 4 +- .../src/module/xep/0446_file_metadata_element.vala | 4 +- 28 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 main/data/icons/scalable/status/dino-clock-alt-symbolic.svg create mode 100644 xmpp-vala/src/module/xep/0202_entity_time.vala diff --git a/dino.doap b/dino.doap index 0b9091ef..96e6913a 100644 --- a/dino.doap +++ b/dino.doap @@ -419,6 +419,14 @@ 0.1 + + + + 2.0 + complete + 0.6 + + diff --git a/dino.doap.in b/dino.doap.in index d58541f7..99e3565d 100644 --- a/dino.doap.in +++ b/dino.doap.in @@ -239,6 +239,14 @@ 0.1 + + + + 2.0 + complete + 0.6 + + diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 737c76c2..ef2d883e 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -52,7 +52,7 @@ public interface Application : GLib.Application { NotificationEvents.start(stream_interactor); SearchProcessor.start(stream_interactor, db); Register.start(stream_interactor, db); - EntityInfo.start(stream_interactor, db); + EntityInfo.start(stream_interactor, db, settings); MessageCorrection.start(stream_interactor, db); FileTransferStorage.start(stream_interactor, db); Reactions.start(stream_interactor, db); diff --git a/libdino/src/entity/settings.vala b/libdino/src/entity/settings.vala index 14ac106e..c71a8e57 100644 --- a/libdino/src/entity/settings.vala +++ b/libdino/src/entity/settings.vala @@ -9,6 +9,7 @@ public class Settings : Object { send_typing_ = col_to_bool_or_default("send_typing", true); send_marker_ = col_to_bool_or_default("send_marker", true); + share_time_ = col_to_bool_or_default("share_time", true); notifications_ = col_to_bool_or_default("notifications", true); convert_utf8_smileys_ = col_to_bool_or_default("convert_utf8_smileys", true); check_spelling = col_to_bool_or_default("check_spelling", true); @@ -43,6 +44,18 @@ public class Settings : Object { } } + private bool share_time_; + public bool share_time { + get { return share_time_; } + set { + db.settings.upsert() + .value(db.settings.key, "share_time", true) + .value(db.settings.value, value.to_string()) + .perform(); + share_time_ = value; + } + } + private bool notifications_; public bool notifications { get { return notifications_; } diff --git a/libdino/src/service/entity_info.vala b/libdino/src/service/entity_info.vala index 83e27d4b..53d14b15 100644 --- a/libdino/src/service/entity_info.vala +++ b/libdino/src/service/entity_info.vala @@ -13,23 +13,35 @@ public class EntityInfo : StreamInteractionModule, Object { private StreamInteractor stream_interactor; private Database db; private EntityCapabilitiesStorage entity_capabilities_storage; - + private Dino.Entities.Settings settings; private HashMap entity_caps_hashes = new HashMap(Jid.hash_func, Jid.equals_func); private HashMap> entity_features = new HashMap>(); private HashMap> jid_features = new HashMap>(Jid.hash_func, Jid.equals_func); private HashMap> entity_identity = new HashMap>(); private HashMap> jid_identity = new HashMap>(Jid.hash_func, Jid.equals_func); + private HashMap jid_utc_offset_minutes = new HashMap(Jid.hash_func, Jid.equals_func); + private HashMap jid_utc_offset_cache_timeout = new HashMap(Jid.hash_func, Jid.equals_func); - public static void start(StreamInteractor stream_interactor, Database db) { - EntityInfo m = new EntityInfo(stream_interactor, db); + public static void start(StreamInteractor stream_interactor, Database db, Dino.Entities.Settings settings) { + EntityInfo m = new EntityInfo(stream_interactor, db, settings); stream_interactor.add_module(m); + WeakRef m_weak = WeakRef(m); + ulong handler_id = settings.notify["share-time"].connect(() => { + EntityInfo? entity_info = m_weak.get() as EntityInfo; + if (entity_info != null) { + entity_info.jid_utc_offset_minutes.clear(); + entity_info.jid_utc_offset_cache_timeout.clear(); + } + }); + m.weak_ref(() => settings.disconnect(handler_id)); } - private EntityInfo(StreamInteractor stream_interactor, Database db) { + private EntityInfo(StreamInteractor stream_interactor, Database db, Dino.Entities.Settings settings) { this.stream_interactor = stream_interactor; this.db = db; this.entity_capabilities_storage = new EntityCapabilitiesStorage(db); + this.settings = settings; stream_interactor.account_added.connect(on_account_added); stream_interactor.connection_manager.stream_opened.connect((account, stream) => { @@ -78,6 +90,45 @@ public class EntityInfo : StreamInteractionModule, Object { return null; } + public async int get_utc_offset_minutes_for_full_jid(Account account, Jid full_jid) { + if (!settings.share_time) return int.MIN; + if (!yield has_feature(account, full_jid, EntityTime.NS_URI)) return int.MIN; + int monotonic_time_minutes = (int) (get_monotonic_time() / 1000000l); + if (jid_utc_offset_minutes.has_key(full_jid)) { + if (jid_utc_offset_cache_timeout[full_jid] > monotonic_time_minutes) { + return jid_utc_offset_minutes.get(full_jid); + } else { + jid_utc_offset_minutes.unset(full_jid); + jid_utc_offset_cache_timeout.unset(full_jid); + } + } + XmppStream? stream = stream_interactor.get_stream(account); + if (stream == null) return int.MIN; + DateTime? time = yield stream.get_module(EntityTime.Module.IDENTITY).query_time(stream, full_jid); + int utc_offset = time == null ? int.MIN : (int) (time.get_utc_offset() / TimeSpan.MINUTE); + jid_utc_offset_minutes.set(full_jid, utc_offset); + jid_utc_offset_cache_timeout.set(full_jid, monotonic_time_minutes + 3 * 60 * 60); + return utc_offset; + } + + public async int get_utc_offset_minutes_for_bare_jid(Account account, Jid bare_jid) { + if (stream_interactor.get_module(RosterManager.IDENTITY).get_roster_item(account, bare_jid) == null) return int.MIN; + int utc_offset_minutes = int.MIN; + Gee.List? full_jids = stream_interactor.get_module(PresenceManager.IDENTITY).get_full_jids(bare_jid, account); + if (full_jids != null) { + foreach (Jid full_jid in full_jids) { + int jid_utc_offset_minutes = yield get_utc_offset_minutes_for_full_jid(account, full_jid); + if (utc_offset_minutes == int.MIN) { + utc_offset_minutes = jid_utc_offset_minutes; + } else if (jid_utc_offset_minutes != int.MIN && utc_offset_minutes != jid_utc_offset_minutes) { + // Return early, time zones don't match + return int.MIN; + } + } + } + return utc_offset_minutes; + } + public async bool has_feature(Account account, Jid jid, string feature) { int has_feature_cached = has_feature_cached_int(account, jid, feature); if (has_feature_cached != -1) { @@ -248,6 +299,10 @@ public class EntityInfo : StreamInteractionModule, Object { private void initialize_modules(Account account, ArrayList modules) { modules.add(new Xep.EntityCapabilities.Module(entity_capabilities_storage)); + var entity_time_module = modules.first_match((it) => it is EntityTime.Module) as EntityTime.Module; + if (entity_time_module != null) { + settings.bind_property("share-time", entity_time_module, "enabled", BindingFlags.SYNC_CREATE); + } } } diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index ef6ecc00..64be76e1 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -67,6 +67,7 @@ public class ModuleManager { module_map[account].add(new Xep.ChatStateNotifications.Module()); module_map[account].add(new Xep.ChatMarkers.Module()); module_map[account].add(new Xep.Ping.Module()); + module_map[account].add(new Xep.EntityTime.Module()); module_map[account].add(new Xep.DelayedDelivery.Module()); module_map[account].add(new StreamError.Module()); module_map[account].add(new Xep.InBandRegistration.Module()); diff --git a/main/data/chat_input.ui b/main/data/chat_input.ui index 87b18946..d0d99729 100644 --- a/main/data/chat_input.ui +++ b/main/data/chat_input.ui @@ -5,6 +5,27 @@ True vertical 4 + + + 14 + 14 + False + + + + dino-clock-alt-symbolic + + + + + 8 + end + + + + 14 diff --git a/main/data/gresource.xml b/main/data/gresource.xml index 85449045..db9e5caf 100644 --- a/main/data/gresource.xml +++ b/main/data/gresource.xml @@ -67,6 +67,7 @@ icons/scalable/status/dino-changes-allow-symbolic.svg icons/scalable/status/dino-changes-prevent-symbolic.svg icons/scalable/status/dino-channel-insecure-symbolic.svg + icons/scalable/status/dino-clock-alt-symbolic.svg icons/scalable/status/dino-dialog-warning-symbolic.svg icons/scalable/status/dino-double-tick-symbolic.svg icons/scalable/status/dino-face-uncertain-symbolic.svg diff --git a/main/data/icons/scalable/status/dino-clock-alt-symbolic.svg b/main/data/icons/scalable/status/dino-clock-alt-symbolic.svg new file mode 100644 index 00000000..1fdd96ce --- /dev/null +++ b/main/data/icons/scalable/status/dino-clock-alt-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/main/data/preferences_window/general_preferences_page.ui b/main/data/preferences_window/general_preferences_page.ui index 9e76e17a..ca240511 100644 --- a/main/data/preferences_window/general_preferences_page.ui +++ b/main/data/preferences_window/general_preferences_page.ui @@ -16,6 +16,13 @@ True + + + Share Local T_ime + Share your current time with contacts to see their local time in return + True + + diff --git a/main/data/style.css b/main/data/style.css index 625534f2..29665ba7 100644 --- a/main/data/style.css +++ b/main/data/style.css @@ -233,6 +233,15 @@ window.dino-main .dino-sidebar frame.auto-complete list > row { transition: none; } +/* Time offset warning */ + +.dino-main .dino-conversation .time-offset { + background-color: var(--shade-color); + opacity: 80%; + padding: 12px; + border-radius: 6px 6px 0 0; +} + /* Chat Input*/ /* These rules are direved from the libadwaita style for activateable entry rows */ diff --git a/main/src/ui/chat_input/view.vala b/main/src/ui/chat_input/view.vala index dbb1f74e..8b57a08d 100644 --- a/main/src/ui/chat_input/view.vala +++ b/main/src/ui/chat_input/view.vala @@ -26,11 +26,18 @@ public class View : Box { [GtkChild] public unowned MenuButton encryption_button; [GtkChild] public unowned Separator file_separator; [GtkChild] public unowned Label chat_input_status; + [GtkChild] public unowned Box unavailable_box; + [GtkChild] public unowned Image unavailable_icon; + [GtkChild] public unowned Label unavailable_label; public EncryptionButton encryption_widget; + private bool is_dnd = false; + private bool is_night = false; + public View init(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; + stream_interactor.get_module(PresenceManager.IDENTITY).show_received.connect(on_show_received); encryption_widget = new EncryptionButton(stream_interactor, encryption_button); @@ -61,9 +68,99 @@ public class View : Box { chat_text_view.text_view.buffer.text = entry_cache[conversation]; } + update_unavailable_message(); + do_focus(); } + private void on_show_received(Jid jid, Account account) { + if (this.conversation.type_ == Conversation.Type.CHAT && this.conversation.counterpart.equals_bare(jid)) { + update_unavailable_message(); + } + } + + private void update_unavailable_message() { + if (conversation.type_ != Conversation.Type.CHAT) { + is_dnd = false; + is_night = false; + set_unavailable_message(null, null); + } else { + PresenceManager presence_manager = stream_interactor.get_module(PresenceManager.IDENTITY); + Gee.List? full_jids = presence_manager.get_full_jids(conversation.counterpart, conversation.account); + if (full_jids != null) { + foreach (Jid full_jid in full_jids) { + if (presence_manager.get_last_show(full_jid, conversation.account) == Presence.Stanza.SHOW_DND) { + if (update_utc_offset_minutes_timeout != 0) { + Source.remove(update_utc_offset_minutes_timeout); + update_utc_offset_minutes_timeout = 0; + } + is_dnd = true; + set_unavailable_message(Markup.printf_escaped(_("%s has paused their notifications"), Util.get_conversation_display_name(stream_interactor, conversation)), "dino-bell-large-none-symbolic", true); + return; + } + } + } + is_dnd = false; + update_utc_offset_minutes(); + } + } + + private uint update_utc_offset_minutes_timeout = 0; + private void update_utc_offset_minutes() { + Conversation conversation = this.conversation; + if (update_utc_offset_minutes_timeout != 0) { + Source.remove(update_utc_offset_minutes_timeout); + update_utc_offset_minutes_timeout = 0; + } + if (conversation.type_ == Conversation.Type.CHAT) { + stream_interactor.get_module(EntityInfo.IDENTITY).get_utc_offset_minutes_for_bare_jid.begin(conversation.account, conversation.counterpart, (_, res) => { + set_utc_offset_minutes(conversation, stream_interactor.get_module(EntityInfo.IDENTITY).get_utc_offset_minutes_for_bare_jid.end(res)); + update_utc_offset_minutes_timeout = WeakTimeout.add_once(60000 - (int) (new DateTime.now_utc().get_seconds()*1000d), this, update_utc_offset_minutes); + }); + } + } + + private void set_utc_offset_minutes(Conversation conversation, int utc_offset_minutes) { + if (this.conversation != conversation) return; + string? display_time = null; + if (utc_offset_minutes != int.MIN && new DateTime.now_local().get_utc_offset() / TimeSpan.MINUTE != utc_offset_minutes) { + var datetime = new DateTime.now(new TimeZone.offset(utc_offset_minutes * 60)); + if (datetime.get_hour() >= 22 || datetime.get_hour() < 8) { + display_time = datetime.format(Util.is_24h_format() ? + /* xgettext:no-c-format */ /* Time in 24h format (w/o seconds) */ _("%H∶%M") : + /* xgettext:no-c-format */ /* Time in 12h format (w/o seconds) */ _("%l∶%M %p")); + } + } + is_night = (display_time != null); + if (!is_dnd) { + if (!is_night) { + set_unavailable_message(null, null); + } else { + set_unavailable_message(Markup.printf_escaped(_("It's %s for %s"), display_time, Util.get_conversation_display_name(stream_interactor, conversation)), "dino-clock-alt-symbolic", true); + } + } + } + + private void set_unavailable_message(string? message, string? icon, bool use_markup = false) { + if (message == null) { + unavailable_box.visible = false; + } else { + if (use_markup) { + unavailable_label.set_markup(message); + } else { + unavailable_label.use_markup = use_markup; + unavailable_label.label = message; + } + unavailable_box.visible = true; + } + if (icon == null) { + unavailable_icon.opacity = 0; + } else { + unavailable_icon.icon_name = icon; + unavailable_icon.opacity = 1; + } + } + public void set_input_state(Plugins.InputFieldStatus.MessageType message_type) { switch (message_type) { case Plugins.InputFieldStatus.MessageType.NONE: diff --git a/main/src/ui/conversation_details.vala b/main/src/ui/conversation_details.vala index 404f357d..f9bae672 100644 --- a/main/src/ui/conversation_details.vala +++ b/main/src/ui/conversation_details.vala @@ -20,6 +20,11 @@ namespace Dino.Ui.ConversationDetails { model.data_form_bak = model.data_form.stanza_node.to_string(); }); } + if (conversation.type_ == Conversation.Type.CHAT) { + stream_interactor.get_module(EntityInfo.IDENTITY).get_utc_offset_minutes_for_bare_jid.begin(conversation.account, conversation.counterpart, (_, res) => { + model.utc_offset_minutes = stream_interactor.get_module(EntityInfo.IDENTITY).get_utc_offset_minutes_for_bare_jid.end(res); + }); + } } public void bind_dialog(Model.ConversationDetails model, ViewModel.ConversationDetails view_model, StreamInteractor stream_interactor) { @@ -100,7 +105,7 @@ namespace Dino.Ui.ConversationDetails { view_model.pin_changed.connect(() => { model.conversation.pinned = model.conversation.pinned == 1 ? 0 : 1; }); - view_model.block_changed.connect((action) => { + view_model.block_changed.connect((view_model, action) => { switch (action) { case USER: stream_interactor.get_module(BlockingManager.IDENTITY).block(model.conversation.account, model.conversation.counterpart); @@ -133,35 +138,67 @@ namespace Dino.Ui.ConversationDetails { } }); - view_model.notification_flipped.connect(() => { + view_model.notification_flipped.connect((view_model) => { model.conversation.notify_setting = view_model.notification == ON ? Conversation.NotifySetting.OFF : Conversation.NotifySetting.ON; }); } + private static string format_utc_offset_minutes(int utc_offset_minutes) { + var datetime = new DateTime.now(new TimeZone.offset(utc_offset_minutes * 60)); + return datetime.format(Util.is_24h_format() ? + /* xgettext:no-c-format */ /* Weekday and time in 24h format (w/o seconds) */ _("%A, %H∶%M") : + /* xgettext:no-c-format */ /* Weekday and time in 12h format (w/o seconds) */ _("%A, %l∶%M %p")); + } + + private uint get_interval_till_next_full_minute() { + return 60000 - (int) (new DateTime.now_utc().get_seconds()*1000d); + } + + private static void notify_binding_once(Binding binding) { + binding.source.notify_property(binding.source_property); + } + public void set_about_rows(Model.ConversationDetails model, ViewModel.ConversationDetails view_model, StreamInteractor stream_interactor) { + Conversation conversation = model.conversation; view_model.about_rows.append(new ViewModel.PreferencesRow.Text() { title = _("XMPP Address"), - text = model.conversation.counterpart.to_string() + text = conversation.counterpart.to_string() }); - if (model.conversation.type_ == Conversation.Type.CHAT) { + if (conversation.type_ == Conversation.Type.CHAT) { + var display_name = model.display_name; var about_row = new ViewModel.PreferencesRow.Entry() { title = _("Display name"), - text = model.display_name.display_name + text = display_name.display_name }; - about_row.changed.connect(() => { - if (about_row.text != model.display_name.display_name) { - stream_interactor.get_module(RosterManager.IDENTITY).set_jid_handle(model.conversation.account, model.conversation.counterpart, about_row.text); + about_row.changed.connect((about_row) => { + if (about_row.text != display_name.display_name) { + stream_interactor.get_module(RosterManager.IDENTITY).set_jid_handle(conversation.account, conversation.counterpart, about_row.text); } }); view_model.about_rows.append(about_row); + var time_row = new ViewModel.PreferencesRow.Text() { + title = _("Local Time") + }; + model.bind_property("utc-offset-minutes", time_row, "visible", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + int utc_offset_minutes = (int) from; + to = utc_offset_minutes != int.MIN; + return true; + }); + model.bind_property("utc-offset-minutes", time_row, "text", BindingFlags.SYNC_CREATE, (binding, from, ref to) => { + var interval = get_interval_till_next_full_minute(); + to = format_utc_offset_minutes((int) from); + WeakTimeout.add_once(interval, binding, notify_binding_once); + return true; + }); + view_model.about_rows.append(time_row); } - if (model.conversation.type_ == Conversation.Type.GROUPCHAT) { - var topic = stream_interactor.get_module(MucManager.IDENTITY).get_groupchat_subject(model.conversation.counterpart, model.conversation.account); + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + var topic = stream_interactor.get_module(MucManager.IDENTITY).get_groupchat_subject(conversation.counterpart, conversation.account); Ui.ViewModel.PreferencesRow.Any preferences_row = null; - Jid? own_muc_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(model.conversation.counterpart, model.conversation.account); + Jid? own_muc_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account); if (own_muc_jid != null) { - Xep.Muc.Role? own_role = stream_interactor.get_module(MucManager.IDENTITY).get_role(own_muc_jid, model.conversation.account); + Xep.Muc.Role? own_role = stream_interactor.get_module(MucManager.IDENTITY).get_role(own_muc_jid, conversation.account); if (own_role != null) { if (own_role == MODERATOR) { var preferences_row_entry = new ViewModel.PreferencesRow.Entry() { @@ -170,7 +207,7 @@ namespace Dino.Ui.ConversationDetails { }; preferences_row_entry.changed.connect(() => { if (preferences_row_entry.text != topic) { - stream_interactor.get_module(MucManager.IDENTITY).change_subject(model.conversation.account, model.conversation.counterpart, preferences_row_entry.text); + stream_interactor.get_module(MucManager.IDENTITY).change_subject(conversation.account, conversation.counterpart, preferences_row_entry.text); } }); preferences_row = preferences_row_entry; @@ -205,8 +242,9 @@ namespace Dino.Ui.ConversationDetails { }); Plugins.ContactDetails contact_details = new Plugins.ContactDetails(); + var settings_rows = dialog.model.settings_rows; contact_details.add_settings_action_row.connect((entry_row_model) => { - dialog.model.settings_rows.append((Ui.ViewModel.PreferencesRow.Any) entry_row_model); + settings_rows.append((Ui.ViewModel.PreferencesRow.Any) entry_row_model); }); Application app = GLib.Application.get_default() as Application; app.plugin_registry.register_contact_details_entry(new ContactDetails.SettingsProvider(stream_interactor)); diff --git a/main/src/ui/util/preference_group.vala b/main/src/ui/util/preference_group.vala index aa680e68..3911bb80 100644 --- a/main/src/ui/util/preference_group.vala +++ b/main/src/ui/util/preference_group.vala @@ -47,63 +47,55 @@ namespace Dino.Ui.Util { return preference_group; } + private static bool null_string_to_empty(GLib.Binding binding, GLib.Value from_value, ref GLib.Value to_value) { + var str = (string) from_value; + to_value = str ?? ""; + return true; + } + public Adw.PreferencesRow? row_to_preference_row(ViewModel.PreferencesRow.Any preferences_row) { + Adw.PreferencesRow? view = null; + var entry_view_model = preferences_row as ViewModel.PreferencesRow.Entry; if (entry_view_model != null) { - Adw.EntryRow view = new Adw.EntryRow() { title = entry_view_model.title, show_apply_button=true }; + view = new Adw.EntryRow() { show_apply_button=true }; if (preferences_row.media_uri != null) { var bytes = Xmpp.get_data_for_uri(preferences_row.media_uri); Picture picture = new Picture.for_paintable(Texture.from_bytes(bytes)); - view.add_suffix(picture); + ((Adw.EntryRow) view).add_suffix(picture); } - entry_view_model.bind_property("text", view, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL, (_, from, ref to) => { - var str = (string) from; - to = str ?? ""; - return true; - }); - view.apply.connect(() => { + entry_view_model.bind_property("text", view, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL, null_string_to_empty); + ((Adw.EntryRow) view).apply.connect(() => { entry_view_model.changed(); }); - return view; } var password_view_model = preferences_row as ViewModel.PreferencesRow.PrivateText; if (password_view_model != null) { - Adw.PasswordEntryRow view = new Adw.PasswordEntryRow() { title = password_view_model.title, show_apply_button=true }; - password_view_model.bind_property("text", view, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL, (_, from, ref to) => { - var str = (string) from; - to = str ?? ""; - return true; - }); - view.apply.connect(() => { + view = new Adw.PasswordEntryRow() { show_apply_button=true }; + password_view_model.bind_property("text", view, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL, null_string_to_empty); + ((Adw.EntryRow) view).apply.connect(() => { password_view_model.changed(); }); - - return view; } var row_text = preferences_row as ViewModel.PreferencesRow.Text; if (row_text != null) { - var view = new Adw.ActionRow() { - title = row_text.title, - subtitle = row_text.text, + view = new Adw.ActionRow() { subtitle_selectable = true }; + row_text.bind_property("text", view, "subtitle", BindingFlags.SYNC_CREATE, null_string_to_empty); view.add_css_class("property"); Util.force_css(view, "row.property > box.header > box.title > .title { font-weight: 400; font-size: 9pt; opacity: 0.55; }"); Util.force_css(view, "row.property > box.header > box.title > .subtitle { font-size: inherit; opacity: 1; }"); - return view; } var toggle_view_model = preferences_row as ViewModel.PreferencesRow.Toggle; if (toggle_view_model != null) { - var view = new Adw.ActionRow() { title = toggle_view_model.title, subtitle = toggle_view_model.subtitle ?? "" }; - var toggle = new Switch() { valign = Align.CENTER }; - view.activatable_widget = toggle; - view.add_suffix(toggle); - toggle_view_model.bind_property("state", toggle, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); - return view; + view = new Adw.SwitchRow(); + toggle_view_model.bind_property("subtitle", view, "subtitle", BindingFlags.SYNC_CREATE, null_string_to_empty); + toggle_view_model.bind_property("state", view, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); } var combobox_view_model = preferences_row as ViewModel.PreferencesRow.ComboBox; @@ -112,21 +104,24 @@ namespace Dino.Ui.Util { foreach (string text in combobox_view_model.items) { string_list.append(text); } - var view = new Adw.ComboRow() { title = combobox_view_model.title, subtitle = combobox_view_model.subtitle ?? "" }; - view.model = string_list; + view = new Adw.ComboRow(); + combobox_view_model.bind_property("subtitle", view, "subtitle", BindingFlags.SYNC_CREATE, null_string_to_empty); + ((Adw.ComboRow)view).model = string_list; combobox_view_model.bind_property("active-item", view, "selected", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); - return view; } var button_view_model = preferences_row as ViewModel.PreferencesRow.Button; if (button_view_model != null) { - var view = new Adw.ActionRow() { title = button_view_model.title, subtitle = button_view_model.subtitle ?? "" }; + view = new Adw.ActionRow(); + button_view_model.bind_property("subtitle", view, "subtitle", BindingFlags.SYNC_CREATE, null_string_to_empty); var button = new Button.with_label(button_view_model.button_text) { valign = Align.CENTER }; - view.add_suffix(button); + ((Adw.ActionRow)view).add_suffix(button); button.clicked.connect(() => button_view_model.clicked()); - return view; } - return null; + preferences_row.bind_property("title", view, "title", BindingFlags.SYNC_CREATE); + preferences_row.bind_property("visible", view, "visible", BindingFlags.SYNC_CREATE); + + return view; } } \ No newline at end of file diff --git a/main/src/view_model/conversation_details.vala b/main/src/view_model/conversation_details.vala index 26379dab..c8da2ee0 100644 --- a/main/src/view_model/conversation_details.vala +++ b/main/src/view_model/conversation_details.vala @@ -84,6 +84,7 @@ public class Dino.Ui.Model.ConversationDetails : Object { public string? data_form_bak; public bool blocked { get; set; } public bool domain_blocked { get; set; } + public int utc_offset_minutes { get; set; default = int.MIN; } public GLib.ListStore members = new GLib.ListStore(typeof(Ui.Model.ConferenceMember)); diff --git a/main/src/view_model/preferences_dialog.vala b/main/src/view_model/preferences_dialog.vala index af00494d..20e11ae3 100644 --- a/main/src/view_model/preferences_dialog.vala +++ b/main/src/view_model/preferences_dialog.vala @@ -109,6 +109,7 @@ public class Dino.Ui.ViewModel.PreferencesDialog : Object { 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("share-time", general_page, "share-time", 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/view_model/preferences_row.vala b/main/src/view_model/preferences_row.vala index 4ca2f2df..d0f64979 100644 --- a/main/src/view_model/preferences_row.vala +++ b/main/src/view_model/preferences_row.vala @@ -6,6 +6,7 @@ using Gee; namespace Dino.Ui.ViewModel.PreferencesRow { public abstract class Any : Object { public string title { get; set; } + public bool visible { get; set; default=true; } public string? media_type { get; set; } public string? media_uri { get; set; } diff --git a/main/src/windows/preferences_window/general_preferences_page.vala b/main/src/windows/preferences_window/general_preferences_page.vala index 1e81a1b7..0dcf2118 100644 --- a/main/src/windows/preferences_window/general_preferences_page.vala +++ b/main/src/windows/preferences_window/general_preferences_page.vala @@ -3,6 +3,7 @@ using Gtk; public class Dino.Ui.ViewModel.GeneralPreferencesPage : Object { public bool send_typing { get; set; } public bool send_marker { get; set; } + public bool share_time { get; set; } public bool notifications { get; set; } public bool convert_emojis { get; set; } } @@ -11,6 +12,7 @@ public class Dino.Ui.ViewModel.GeneralPreferencesPage : Object { public class Dino.Ui.GeneralPreferencesPage : Adw.PreferencesPage { [GtkChild] private unowned Adw.SwitchRow typing_row; [GtkChild] private unowned Adw.SwitchRow marker_row; + [GtkChild] private unowned Adw.SwitchRow time_row; [GtkChild] private unowned Adw.SwitchRow notification_row; [GtkChild] private unowned Adw.SwitchRow emoji_row; @@ -29,6 +31,7 @@ public class Dino.Ui.GeneralPreferencesPage : Adw.PreferencesPage { model_bindings = new Binding[] { model.bind_property("send-typing", typing_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), model.bind_property("send-marker", marker_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("share-time", time_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), model.bind_property("notifications", notification_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), model.bind_property("convert-emojis", emoji_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL) }; diff --git a/xmpp-vala/meson.build b/xmpp-vala/meson.build index 4769a8a6..b035e367 100644 --- a/xmpp-vala/meson.build +++ b/xmpp-vala/meson.build @@ -93,6 +93,7 @@ sources = files( 'src/module/xep/0191_blocking_command.vala', 'src/module/xep/0198_stream_management.vala', 'src/module/xep/0199_ping.vala', + 'src/module/xep/0202_entity_time.vala', 'src/module/xep/0203_delayed_delivery.vala', 'src/module/xep/0215_external_service_discovery.vala', 'src/module/xep/0231_bits_of_binary.vala', diff --git a/xmpp-vala/src/module/iq/module.vala b/xmpp-vala/src/module/iq/module.vala index 172aa9b1..bb9260c9 100644 --- a/xmpp-vala/src/module/iq/module.vala +++ b/xmpp-vala/src/module/iq/module.vala @@ -19,11 +19,20 @@ namespace Xmpp.Iq { Iq.Stanza? return_stanza = null; responseListeners[iq.id] = new ResponseListener((_, result_iq) => { return_stanza = result_iq; - Idle.add(send_iq_async.callback); + if (cancellable == null || !cancellable.is_cancelled()) { + Idle.add(send_iq_async.callback); + } }); stream.write_async(iq.stanza, io_priority, cancellable); + ulong cancelled_handler_id = 0; + if (cancellable != null) { + cancelled_handler_id = cancellable.cancelled.connect(() => Idle.add(send_iq_async.callback)); + } yield; - cancellable.set_error_if_cancelled(); + if (cancellable != null) { + cancellable.disconnect(cancelled_handler_id); + cancellable.set_error_if_cancelled(); + } return return_stanza; } diff --git a/xmpp-vala/src/module/stanza_error.vala b/xmpp-vala/src/module/stanza_error.vala index 5e872291..1a69fd30 100644 --- a/xmpp-vala/src/module/stanza_error.vala +++ b/xmpp-vala/src/module/stanza_error.vala @@ -88,6 +88,9 @@ namespace Xmpp { public ErrorStanza.feature_not_implemented(string? human_readable = null) { this.build(TYPE_MODIFY, CONDITION_FEATURE_NOT_IMPLEMENTED, human_readable, null); } + public ErrorStanza.forbidden(string? human_readable = null) { + this.build(TYPE_AUTH, CONDITION_FORBIDDEN, human_readable, null); + } public ErrorStanza.item_not_found(StanzaNode? application_condition = null) { this.build(TYPE_CANCEL, CONDITION_ITEM_NOT_FOUND, null, application_condition); } diff --git a/xmpp-vala/src/module/xep/0045_muc/module.vala b/xmpp-vala/src/module/xep/0045_muc/module.vala index f660a1cc..88ff819f 100644 --- a/xmpp-vala/src/module/xep/0045_muc/module.vala +++ b/xmpp-vala/src/module/xep/0045_muc/module.vala @@ -100,7 +100,7 @@ public class Module : XmppStreamModule { x_node.put_node(history_node); if (history_since != null) { - history_node.set_attribute("since", DateTimeProfiles.to_datetime(history_since)); + history_node.set_attribute("since", DateTimeProfiles.format_time(history_since)); } else if (!receive_history) { history_node.set_attribute("maxchars", "0"); } diff --git a/xmpp-vala/src/module/xep/0082_date_time_profiles.vala b/xmpp-vala/src/module/xep/0082_date_time_profiles.vala index 8b40d3ac..3237208e 100644 --- a/xmpp-vala/src/module/xep/0082_date_time_profiles.vala +++ b/xmpp-vala/src/module/xep/0082_date_time_profiles.vala @@ -1,11 +1,40 @@ namespace Xmpp.Xep.DateTimeProfiles { -public DateTime? parse_string(string time_string) { +public DateTime? parse_time(string time_string) { return new DateTime.from_iso8601(time_string, null); } -public string to_datetime(DateTime time) { +public TimeZone? parse_tzd(string tzd) { + if (tzd == "Z") return new TimeZone.utc(); + // We support parsing TZD without positive or negative sign prefix for compatibility + string unprefixed; + if (tzd.length == 5) { + unprefixed = tzd; + } else if (tzd.length == 6 && (tzd.has_prefix("-") || tzd.has_prefix("+"))) { + unprefixed = tzd.substring(1); + } else { + return null; + } + int h_offset = unprefixed.substring(0, 2).to_int(); + int m_offset = unprefixed.substring(3, 2).to_int(); + int offset = h_offset * 60 * 60 + m_offset * 60; + if (tzd.has_prefix("-")) { + offset = -offset; + } + return new TimeZone.offset(offset); +} + +public string format_time(DateTime time) { return time.to_utc().format_iso8601().to_string(); } +public string format_tzd(DateTime time) { + TimeSpan utc_offset = time.get_utc_offset(); + if (utc_offset == 0) return "Z"; + int m_offset = (int) (utc_offset / TimeSpan.MINUTE).abs() % 60; + int h_offset = (int) (utc_offset / TimeSpan.HOUR).abs(); + string prefix = utc_offset < 0 ? "-" : "+"; + return "%s%02d:%02d".printf(prefix, h_offset, m_offset); +} + } diff --git a/xmpp-vala/src/module/xep/0202_entity_time.vala b/xmpp-vala/src/module/xep/0202_entity_time.vala new file mode 100644 index 00000000..9421d921 --- /dev/null +++ b/xmpp-vala/src/module/xep/0202_entity_time.vala @@ -0,0 +1,75 @@ +using Gee; + +namespace Xmpp.Xep.EntityTime { + public const string NS_URI = "urn:xmpp:time"; + + public class Module : XmppStreamModule, Iq.Handler { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "0202_entity_time"); + public bool enabled { get; set; default = false; } + + private HashMap> active_time_requests = new HashMap>(Jid.hash_func, Jid.equals_func); + + public async DateTime? query_time(XmppStream stream, Jid jid) { + if (!enabled) return null; + + var future = active_time_requests[jid]; + if (future != null) { + try { + return yield future.wait_async(); + } catch (FutureError error) { + return null; + } + } + + var promise = new Promise(); + active_time_requests[jid] = promise.future; + + DateTime? res = null; + try { + Iq.Stanza iq = new Iq.Stanza.get(new StanzaNode.build("time", NS_URI).add_self_xmlns()) { to=jid }; + var cancellable = new Cancellable(); + var timeout_id = Timeout.add_seconds_once(10, () => cancellable.cancel()); + Iq.Stanza result = yield stream.get_module(Iq.Module.IDENTITY).send_iq_async(stream, iq, Priority.HIGH, cancellable); + Source.remove(timeout_id); + StanzaNode time_node = result.stanza.get_subnode("time", NS_URI); + if (time_node == null) return null; + StanzaNode tzo_node = time_node.get_subnode("tzo", NS_URI); + StanzaNode utc_node = time_node.get_subnode("utc", NS_URI); + TimeZone? tzo = null; + if (utc_node != null) res = DateTimeProfiles.parse_time(utc_node.get_string_content()); + if (tzo_node != null) tzo = DateTimeProfiles.parse_tzd(tzo_node.get_string_content()); + if (res != null && tzo != null) res = res.to_timezone(tzo); + promise.set_value(res); + } catch (IOError e) { + debug("Error while fetching time: %s", e.message); + } + return res; + } + + public override void attach(XmppStream stream) { + stream.get_module(Iq.Module.IDENTITY).register_for_namespace(NS_URI, this); + stream.get_module(ServiceDiscovery.Module.IDENTITY).add_feature(stream, NS_URI); + } + + public override void detach(XmppStream stream) { + stream.get_module(Iq.Module.IDENTITY).unregister_from_namespace(NS_URI, this); + stream.get_module(ServiceDiscovery.Module.IDENTITY).remove_feature(stream, NS_URI); + } + + public async void on_iq_get(XmppStream stream, Iq.Stanza iq) { + var roster_flag = stream.get_flag(Roster.Flag.IDENTITY); + if (!enabled || roster_flag == null || roster_flag.get_item(iq.from.bare_jid) == null) { + stream.get_module(Iq.Module.IDENTITY).send_iq(stream, new Iq.Stanza.error(iq, new ErrorStanza.forbidden()), null, Priority.LOW); + } else { + DateTime time = new DateTime.now_local(); + StanzaNode time_node = new StanzaNode.build("time", NS_URI).add_self_xmlns(); + time_node.put_node(new StanzaNode.build("utc", NS_URI).put_node(new StanzaNode.text(DateTimeProfiles.format_time(time)))); + time_node.put_node(new StanzaNode.build("tzo", NS_URI).put_node(new StanzaNode.text(DateTimeProfiles.format_tzd(time)))); + stream.get_module(Iq.Module.IDENTITY).send_iq(stream, new Iq.Stanza.result(iq, time_node), null, Priority.LOW); + } + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + } +} diff --git a/xmpp-vala/src/module/xep/0203_delayed_delivery.vala b/xmpp-vala/src/module/xep/0203_delayed_delivery.vala index ae344d2e..c4be0ad3 100644 --- a/xmpp-vala/src/module/xep/0203_delayed_delivery.vala +++ b/xmpp-vala/src/module/xep/0203_delayed_delivery.vala @@ -4,7 +4,7 @@ public const string NS_URI = "urn:xmpp:delay"; public static DateTime? get_time_for_node(StanzaNode node) { string? time = node.get_attribute("stamp"); - if (time != null) return DateTimeProfiles.parse_string(time); + if (time != null) return DateTimeProfiles.parse_time(time); return null; } @@ -24,7 +24,7 @@ public class Module : XmppStreamModule { public static void set_message_delay(MessageStanza message, DateTime datetime) { StanzaNode delay_node = (new StanzaNode.build("delay", NS_URI)).add_self_xmlns(); - delay_node.put_attribute("stamp", DateTimeProfiles.to_datetime(datetime)); + delay_node.put_attribute("stamp", DateTimeProfiles.format_time(datetime)); message.stanza.put_node(delay_node); } diff --git a/xmpp-vala/src/module/xep/0215_external_service_discovery.vala b/xmpp-vala/src/module/xep/0215_external_service_discovery.vala index 6d41a288..5bad71cd 100644 --- a/xmpp-vala/src/module/xep/0215_external_service_discovery.vala +++ b/xmpp-vala/src/module/xep/0215_external_service_discovery.vala @@ -26,7 +26,7 @@ namespace Xmpp.Xep.ExternalServiceDiscovery { service.username = service_node.get_attribute("username", NS_URI); service.password = service_node.get_attribute("password", NS_URI); string? expires_str = service_node.get_attribute("expires", NS_URI); - if (expires_str != null) service.expires = DateTimeProfiles.parse_string(expires_str); + if (expires_str != null) service.expires = DateTimeProfiles.parse_time(expires_str); service.transport = service_node.get_attribute("transport", NS_URI); service.name = service_node.get_attribute("name", NS_URI); diff --git a/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala b/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala index b25f6106..298a1c61 100644 --- a/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala +++ b/xmpp-vala/src/module/xep/0313_2_message_archive_management.vala @@ -49,12 +49,12 @@ namespace Xmpp.MessageArchiveManagement.V2 { } if (mam_params.start != null) { DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="start" }; - field.set_value_string(DateTimeProfiles.to_datetime(mam_params.start)); + field.set_value_string(DateTimeProfiles.format_time(mam_params.start)); fields.add(field); } if (mam_params.end != null && mam_params.end_id == null && !mam_params.use_ns2_extended) { DataForms.DataForm.Field field = new DataForms.DataForm.Field() { var="end" }; - field.set_value_string(DateTimeProfiles.to_datetime(mam_params.end)); + field.set_value_string(DateTimeProfiles.format_time(mam_params.end)); fields.add(field); } diff --git a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala index ebdcbcfa..b4c4367c 100644 --- a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala +++ b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala @@ -29,7 +29,7 @@ namespace Xmpp.Xep.FileMetadataElement { } if (this.date != null) { node.put_node(new StanzaNode.build("date", NS_URI).put_node( - new StanzaNode.text(DateTimeProfiles.to_datetime(this.date)) + new StanzaNode.text(DateTimeProfiles.format_time(this.date)) )); } if (this.desc != null) { @@ -84,7 +84,7 @@ namespace Xmpp.Xep.FileMetadataElement { } StanzaNode? date_node = file_node.get_subnode("date"); if (date_node != null && date_node.get_string_content() != null) { - metadata.date = DateTimeProfiles.parse_string(date_node.get_string_content()); + metadata.date = DateTimeProfiles.parse_time(date_node.get_string_content()); } StanzaNode? width_node = file_node.get_subnode("width"); if (width_node != null && width_node.get_string_content() != null) { -- cgit v1.3