diff options
45 files changed, 2113 insertions, 225 deletions
diff --git a/libdino/src/application.vala b/libdino/src/application.vala index f2e3cc9b..13bcd471 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -19,9 +19,11 @@ public interface Application : GLib.Application { public abstract SearchPathGenerator? search_path_generator { get; set; } internal static string print_xmpp; + internal static bool ignore_tls_errors = false; private const OptionEntry[] options = { { "print-xmpp", 0, 0, OptionArg.STRING, ref print_xmpp, "Print XMPP stanzas identified by DESC to stderr", "DESC" }, + { "ignore-tls-errors", 0, 0, OptionArg.NONE, ref ignore_tls_errors, "Ignore TLS certificate errors", null }, { null } }; diff --git a/libdino/src/dbus/notifications.vala b/libdino/src/dbus/notifications.vala index 400d1da0..fd07b570 100644 --- a/libdino/src/dbus/notifications.vala +++ b/libdino/src/dbus/notifications.vala @@ -7,6 +7,8 @@ namespace Dino { public signal void notification_closed (uint32 id, uint32 reason); + public signal void notification_replied(uint32 id, string text); + public abstract async uint32 notify(string app_name, uint32 replaces_id, string app_icon, string summary, string body, string[] actions, HashTable<string, Variant> hints, int32 expire_timeout) throws DBusError, IOError; @@ -51,4 +53,4 @@ namespace Dino { return ret; } -}
\ No newline at end of file +} diff --git a/libdino/src/entity/conversation.vala b/libdino/src/entity/conversation.vala index 8c1063c7..3853389c 100644 --- a/libdino/src/entity/conversation.vala +++ b/libdino/src/entity/conversation.vala @@ -118,12 +118,20 @@ public class Conversation : Object { } public NotifySetting get_notification_setting(StreamInteractor stream_interactor) { - return notify_setting != NotifySetting.DEFAULT ? notify_setting : get_notification_default_setting(stream_interactor); + return get_notification_setting_for(stream_interactor, Application.get_default().settings.notifications); + } + + public NotifySetting get_notification_setting_for(StreamInteractor stream_interactor, bool notifications_enabled) { + if (!notifications_enabled) return NotifySetting.OFF; + return notify_setting != NotifySetting.DEFAULT ? notify_setting : get_notification_default_setting_for(stream_interactor); } public NotifySetting get_notification_default_setting(StreamInteractor stream_interactor) { if (!Application.get_default().settings.notifications) return NotifySetting.OFF; + return get_notification_default_setting_for(stream_interactor); + } + private NotifySetting get_notification_default_setting_for(StreamInteractor stream_interactor) { if (type_ == Type.GROUPCHAT) { if (stream_interactor.get_module(MucManager.IDENTITY).is_private_room(this.account, this.counterpart)) { return NotifySetting.ON; diff --git a/libdino/src/entity/settings.vala b/libdino/src/entity/settings.vala index c71a8e57..d03bb39f 100644 --- a/libdino/src/entity/settings.vala +++ b/libdino/src/entity/settings.vala @@ -2,6 +2,8 @@ namespace Dino.Entities { public class Settings : Object { + public signal void account_color_changed(Account account, string color); + private Database db; public Settings.from_db(Database db) { @@ -11,8 +13,17 @@ public class Settings : Object { 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); + notifications_files_ = col_to_bool_or_default("notifications_files", notifications_); + notifications_calls_ = col_to_bool_or_default("notifications_calls", true); + notifications_subscription_requests_ = col_to_bool_or_default("notifications_subscription_requests", true); + notifications_group_chat_invitations_ = col_to_bool_or_default("notifications_group_chat_invitations", true); + notifications_permission_requests_ = col_to_bool_or_default("notifications_permission_requests", true); + notifications_connection_errors_ = col_to_bool_or_default("notifications_connection_errors", true); + notifications_badge_ = col_to_bool_or_default("notifications_badge", true); convert_utf8_smileys_ = col_to_bool_or_default("convert_utf8_smileys", true); check_spelling = col_to_bool_or_default("check_spelling", true); + avatar_roundness_ = col_to_int_or_default("avatar_roundness", 20).clamp(0, 100); + custom_css_ = db.settings.select({db.settings.value}).with(db.settings.key, "=", "custom_css")[db.settings.value] ?? ""; } private bool col_to_bool_or_default(string key, bool def) { @@ -20,6 +31,12 @@ public class Settings : Object { return val != null ? bool.parse(val) : def; } + private int col_to_int_or_default(string key, int def) { + string? val = db.settings.select({db.settings.value}).with(db.settings.key, "=", key)[db.settings.value]; + int parsed = def; + return val != null && int.try_parse(val, out parsed) ? parsed : def; + } + private bool send_typing_; public bool send_typing { get { return send_typing_; } @@ -68,6 +85,90 @@ public class Settings : Object { } } + private bool notifications_files_; + public bool notifications_files { + get { return notifications_files_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_files", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_files_ = value; + } + } + + private bool notifications_calls_; + public bool notifications_calls { + get { return notifications_calls_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_calls", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_calls_ = value; + } + } + + private bool notifications_subscription_requests_; + public bool notifications_subscription_requests { + get { return notifications_subscription_requests_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_subscription_requests", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_subscription_requests_ = value; + } + } + + private bool notifications_group_chat_invitations_; + public bool notifications_group_chat_invitations { + get { return notifications_group_chat_invitations_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_group_chat_invitations", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_group_chat_invitations_ = value; + } + } + + private bool notifications_permission_requests_; + public bool notifications_permission_requests { + get { return notifications_permission_requests_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_permission_requests", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_permission_requests_ = value; + } + } + + private bool notifications_connection_errors_; + public bool notifications_connection_errors { + get { return notifications_connection_errors_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_connection_errors", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_connection_errors_ = value; + } + } + + private bool notifications_badge_; + public bool notifications_badge { + get { return notifications_badge_; } + set { + db.settings.upsert() + .value(db.settings.key, "notifications_badge", true) + .value(db.settings.value, value.to_string()) + .perform(); + notifications_badge_ = value; + } + } + private bool convert_utf8_smileys_; public bool convert_utf8_smileys { get { return convert_utf8_smileys_; } @@ -93,6 +194,31 @@ public class Settings : Object { } } + private int avatar_roundness_; + public int avatar_roundness { + get { return avatar_roundness_; } + set { + int clamped_value = value.clamp(0, 100); + db.settings.upsert() + .value(db.settings.key, "avatar_roundness", true) + .value(db.settings.value, clamped_value.to_string()) + .perform(); + avatar_roundness_ = clamped_value; + } + } + + private string custom_css_; + public string custom_css { + get { return custom_css_; } + set { + db.settings.upsert() + .value(db.settings.key, "custom_css", true) + .value(db.settings.value, value) + .perform(); + custom_css_ = value; + } + } + public Encryption get_default_encryption(Account account) { string? setting = db.account_settings.get_value(account.id, "default-encryption"); if (setting != null) { @@ -110,6 +236,27 @@ public class Settings : Object { } + + public string get_account_color(Account account) { + string? setting = db.account_settings.get_value(account.id, "conversation-list-color"); + if (setting != null && setting.length > 0) { + return setting; + } + + uint8[] rgb = Xmpp.Xep.ConsistentColor.string_to_rgb(account.bare_jid.to_string()); + return "#%02x%02x%02x".printf(rgb[0], rgb[1], rgb[2]).up(); + } + + public void set_account_color(Account account, string color) { + if (color == get_account_color(account)) return; + + db.account_settings.upsert() + .value(db.account_settings.key, "conversation-list-color", true) + .value(db.account_settings.account_id, account.id, true) + .value(db.account_settings.value, color) + .perform(); + account_color_changed(account, color); + } } } diff --git a/libdino/src/service/chat_interaction.vala b/libdino/src/service/chat_interaction.vala index 1e988382..def9ea86 100644 --- a/libdino/src/service/chat_interaction.vala +++ b/libdino/src/service/chat_interaction.vala @@ -51,6 +51,24 @@ public class ChatInteraction : StreamInteractionModule, Object { return (int) query.count(); } + public async void mark_all_as_read() { + ContentItemStore content_item_store = stream_interactor.get_module(ContentItemStore.IDENTITY); + CounterpartInteractionManager counterpart_interaction_manager = stream_interactor.get_module(CounterpartInteractionManager.IDENTITY); + + foreach (Conversation conversation in stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations()) { + if (get_num_unread(conversation) == 0) continue; + + ContentItem? latest_item = content_item_store.get_latest(conversation); + if (latest_item == null) continue; + + Message? message = content_item_store.get_message_for_content_item(conversation, latest_item); + if (message != null) { + yield send_and_mark_message_displayed(conversation, message); + } + counterpart_interaction_manager.mark_displayed_up_to_content_item(conversation, latest_item); + } + } + public bool is_active_focus(Conversation? conversation = null) { if (conversation != null) { return focus_in && conversation.equals(this.selected_conversation); diff --git a/libdino/src/service/connection_manager.vala b/libdino/src/service/connection_manager.vala index 2ebddd92..afae1da8 100644 --- a/libdino/src/service/connection_manager.vala +++ b/libdino/src/service/connection_manager.vala @@ -384,6 +384,9 @@ public class ConnectionManager : Object { } public static bool on_invalid_certificate(string domain, TlsCertificate peer_cert, TlsCertificateFlags errors) { + if (Application.ignore_tls_errors) { + return true; + } if (domain.has_suffix(".onion") && errors == TlsCertificateFlags.UNKNOWN_CA) { // It's barely possible for .onion servers to provide a non-self-signed cert. // But that's fine because encryption is provided independently though TOR. diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala index 55d121e2..5f04bdbc 100644 --- a/libdino/src/service/message_correction.vala +++ b/libdino/src/service/message_correction.vala @@ -59,22 +59,7 @@ public class MessageCorrection : StreamInteractionModule, MessageListener { } public bool is_own_correction_allowed(Conversation conversation, Message message) { - string stanza_id = message.edit_to ?? message.stanza_id; - - Jid? own_jid = null; - if (conversation.type_ == Conversation.Type.CHAT) { - own_jid = stream_interactor.get_account_full_jid(conversation.account); - if (own_jid == null) return false; - Message other = stream_interactor.get_module(MessageStorage.IDENTITY).get_last_message_by_sent_resource(conversation, own_jid.resourcepart); - return other != null && other.id == message.id; - } else if (conversation.type_ == Conversation.Type.GROUPCHAT) { - own_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account); - if (own_jid == null) return false; - return last_messages.has_key(conversation) && - last_messages[conversation].has_key(own_jid) && - last_messages[conversation][own_jid].stanza_id == stanza_id; - } - return false; + return message.direction == Message.DIRECTION_SENT; } private void check_add_correction_node(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { diff --git a/libdino/src/service/notification_events.vala b/libdino/src/service/notification_events.vala index 3e077dfc..d22d54a4 100644 --- a/libdino/src/service/notification_events.vala +++ b/libdino/src/service/notification_events.vala @@ -7,6 +7,7 @@ namespace Dino { public class NotificationEvents : StreamInteractionModule, Object { public static ModuleIdentity<NotificationEvents> IDENTITY = new ModuleIdentity<NotificationEvents>("notification_events"); + private const int MESSAGE_NOTIFICATION_MAX_AGE_SECONDS = 15; public string id { get { return IDENTITY.id; } } public signal void notify_content_item(ContentItem content_item, Conversation conversation); @@ -52,9 +53,6 @@ public class NotificationEvents : StreamInteractionModule, Object { if (item.id == conversation.read_up_to_item) return; if (stream_interactor.get_module(ChatInteraction.IDENTITY).is_active_focus()) return; - Conversation.NotifySetting notify = conversation.get_notification_setting(stream_interactor); - if (notify == Conversation.NotifySetting.OFF) return; - string conversation_display_name = get_conversation_display_name(stream_interactor, conversation, null); string? participant_display_name = null; if (conversation.type_ == Conversation.Type.GROUPCHAT) { @@ -63,11 +61,16 @@ public class NotificationEvents : StreamInteractionModule, Object { switch (item.type_) { case MessageItem.TYPE: + Conversation.NotifySetting message_notify = conversation.get_notification_setting(stream_interactor); + if (message_notify == Conversation.NotifySetting.OFF) return; + Message message = ((MessageItem) item).message; if (message.direction == Message.DIRECTION_SENT) return; + DateTime? message_time = message.time; + if (message_time != null && message_time.compare(new DateTime.now_utc().add_seconds(-MESSAGE_NOTIFICATION_MAX_AGE_SECONDS)) < 0) return; - if (notify == Conversation.NotifySetting.HIGHLIGHT) { + if (message_notify == Conversation.NotifySetting.HIGHLIGHT) { Jid? nick = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account); if (nick == null) return; @@ -76,21 +79,25 @@ public class NotificationEvents : StreamInteractionModule, Object { } notify_content_item(item, conversation); - if (notify != Conversation.NotifySetting.OFF) { + if (message_notify != Conversation.NotifySetting.OFF) { NotificationProvider notifier = yield notifier.wait_async(); yield notifier.notify_message(message, conversation, conversation_display_name, participant_display_name); } break; case FileItem.TYPE: + Conversation.NotifySetting file_notify = conversation.get_notification_setting_for( + stream_interactor, Application.get_default().settings.notifications_files); + if (file_notify == Conversation.NotifySetting.OFF) return; + FileTransfer file_transfer = ((FileItem) item).file_transfer; bool is_image = file_transfer.content_type != null && file_transfer.content_type.is_image(); // Don't notify on file transfers in a groupchat set to "mention only" - if (notify == Conversation.NotifySetting.HIGHLIGHT) return; + if (file_notify == Conversation.NotifySetting.HIGHLIGHT) return; if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return; notify_content_item(item, conversation); - if (notify != Conversation.NotifySetting.OFF) { + if (file_notify != Conversation.NotifySetting.OFF) { NotificationProvider notifier = yield notifier.wait_async(); yield notifier.notify_file(file_transfer, conversation, is_image, conversation_display_name, participant_display_name); } @@ -102,6 +109,8 @@ public class NotificationEvents : StreamInteractionModule, Object { } private async void on_voice_request_received(Account account, Jid room_jid, Jid from_jid, string nick) { + if (!Application.get_default().settings.notifications_permission_requests) return; + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(room_jid, account, Conversation.Type.GROUPCHAT); if (conversation == null) return; @@ -110,6 +119,8 @@ public class NotificationEvents : StreamInteractionModule, Object { } private async void on_received_subscription_request(Jid jid, Account account) { + if (!Application.get_default().settings.notifications_subscription_requests) return; + Conversation conversation = stream_interactor.get_module(ConversationManager.IDENTITY).create_conversation(jid, account, Conversation.Type.CHAT); if (stream_interactor.get_module(ChatInteraction.IDENTITY).is_active_focus(conversation)) return; @@ -118,6 +129,7 @@ public class NotificationEvents : StreamInteractionModule, Object { } private async void on_call_incoming(Call call, CallState call_state, Conversation conversation, bool video, bool multiparty) { + if (!Application.get_default().settings.notifications_calls) return; if (!stream_interactor.get_module(Calls.IDENTITY).can_we_do_calls(call.account)) return; string conversation_display_name = get_conversation_display_name(stream_interactor, conversation, null); @@ -131,6 +143,8 @@ public class NotificationEvents : StreamInteractionModule, Object { } private async void on_invite_received(Account account, Jid room_jid, Jid from_jid, string? password, string? reason) { + if (!Application.get_default().settings.notifications_group_chat_invitations) return; + string inviter_display_name; if (room_jid.equals_bare(from_jid)) { Conversation conversation = new Conversation(room_jid, account, Conversation.Type.GROUPCHAT); @@ -144,6 +158,8 @@ public class NotificationEvents : StreamInteractionModule, Object { } private async void on_connection_error(Account account, ConnectionManager.ConnectionError error) { + if (!Application.get_default().settings.notifications_connection_errors) return; + NotificationProvider notifier = yield notifier.wait_async(); yield notifier.notify_connection_error(account, error); } diff --git a/main/data/chat_input.ui b/main/data/chat_input.ui index c440d815..1f6396d6 100644 --- a/main/data/chat_input.ui +++ b/main/data/chat_input.ui @@ -4,7 +4,6 @@ <template class="DinoUiChatInputView"> <property name="hexpand">True</property> <property name="orientation">vertical</property> - <property name="margin-top">4</property> <child> <object class="GtkBox" id="unavailable_box"> <property name="margin_start">14</property> @@ -27,15 +26,24 @@ </object> </child> <child> + <object class="GtkLabel" id="chat_input_status"> + <property name="xalign">0</property> + <property name="margin_bottom">3</property> + <property name="margin_start">14</property> + <property name="margin_end">14</property> + <property name="wrap">True</property> + <attributes> + <attribute name="scale" value="0.8"></attribute> + </attributes> + <style> + <class name="chat-input-status"/> + </style> + </object> + </child> + <child> <object class="GtkBox" id="outer_box"> - <property name="margin_start">14</property> - <property name="margin_end">14</property> <style> <class name="dino-chatinput"/> - <class name="card"/> - <class name="toolbar"/> - <class name="activatable"/> - <class name="compact-card-toolbar"/> </style> <child> <object class="GtkButton" id="file_button"> @@ -126,20 +134,5 @@ </child> </object> </child> - <child> - <object class="GtkLabel" id="chat_input_status"> - <property name="xalign">0</property> - <property name="margin_bottom">3</property> - <property name="margin_start">14</property> - <property name="margin_end">14</property> - <property name="wrap">True</property> - <attributes> - <attribute name="scale" value="0.8"></attribute> - </attributes> - <style> - <class name="chat-input-status"/> - </style> - </object> - </child> </template> </interface> diff --git a/main/data/conversation_content_view/view.ui b/main/data/conversation_content_view/view.ui index c5ee7a92..24ce5476 100644 --- a/main/data/conversation_content_view/view.ui +++ b/main/data/conversation_content_view/view.ui @@ -31,6 +31,7 @@ <class name="card"/> <class name="toolbar"/> <class name="compact-card-toolbar"/> + <class name="message-actions"/> </style> </object> </child> diff --git a/main/data/conversation_row.ui b/main/data/conversation_row.ui index 9b113427..461c1440 100644 --- a/main/data/conversation_row.ui +++ b/main/data/conversation_row.ui @@ -14,6 +14,13 @@ <property name="margin-start">2</property> <property name="margin-end">4</property> <child> + <object class="GtkDrawingArea" id="account_color_indicator"> + <property name="width-request">4</property> + <property name="margin-end">6</property> + <property name="vexpand">True</property> + </object> + </child> + <child> <object class="DinoUiAvatarPicture" id="picture"> <property name="height-request">35</property> <property name="width-request">35</property> diff --git a/main/data/gresource.xml b/main/data/gresource.xml index 86716ce9..29d9c7d3 100644 --- a/main/data/gresource.xml +++ b/main/data/gresource.xml @@ -94,6 +94,7 @@ <file>preferences_window/change_password_dialog.ui</file> <file>preferences_window/general_preferences_page.ui</file> <file>preferences_window/preferences_dialog.ui</file> + <file>preferences_window/tweaks_preferences_page.ui</file> <file>join_room_dialog.ui</file> <file>join_room_dialog1.ui</file> @@ -101,7 +102,6 @@ <file>main_window.ui</file> <file>main_window_placeholder.ui</file> <file>manage_key_dialog.ui</file> - <file>menu_add.ui</file> <file>menu_app.ui</file> <file>menu_conversation.ui</file> <file>menu_encryption.ui</file> diff --git a/main/data/main_window.ui b/main/data/main_window.ui index 6ca4e915..e62c952b 100644 --- a/main/data/main_window.ui +++ b/main/data/main_window.ui @@ -2,15 +2,19 @@ <interface> <requires lib="gtk" version="4.0"/> <requires lib="libadwaita" version="1.4"/> - <template class="DinoUiMainWindow" parent="AdwApplicationWindow"> - <child> - <object class="AdwBreakpoint"> - <condition>max-width: 600sp</condition> - <setter object="navigation_split_view" property="collapsed">True</setter> - </object> - </child> - <property name="content"> - <object class="GtkStack" id="stack"> + <template class="DinoUiMainWindow" parent="GtkApplicationWindow"> + <property name="child"> + <object class="AdwBreakpointBin"> + <property name="width-request">360</property> + <property name="height-request">320</property> + <child> + <object class="AdwBreakpoint"> + <condition>max-width: 600sp</condition> + <setter object="navigation_split_view" property="collapsed">True</setter> + </object> + </child> + <property name="child"> + <object class="GtkStack" id="stack"> <child> <object class="GtkStackPage"> <property name="name">main</property> @@ -24,26 +28,16 @@ <property name="child"> <object class="AdwToolbarView"> <child type="top"> - <object class="AdwHeaderBar" id="header_bar"> - <property name="show-title">False</property> - <property name="hexpand">False</property> + <object class="GtkBox" id="conversation_list_toolbar"> + <property name="spacing">6</property> <style> - <class name="dino-left"/> + <class name="toolbar"/> + <class name="dino-conversation-list-toolbar"/> </style> <child> - <object class="GtkMenuButton" id="add_button"> - <property name="has-frame">False</property> - <child> - <object class="GtkImage"> - <property name="icon-name">dino-list-add-symbolic</property> - <property name="icon-size">normal</property> - </object> - </child> - </object> - </child> - <child type="end"> <object class="GtkMenuButton" id="menu_button"> <property name="has-frame">False</property> + <property name="valign">center</property> <child> <object class="GtkImage"> <property name="icon-name">dino-open-menu-symbolic</property> @@ -52,6 +46,16 @@ </child> </object> </child> + <child> + <object class="GtkSearchEntry" id="conversation_search_entry"> + <property name="hexpand">True</property> + <property name="valign">center</property> + <property name="placeholder-text" translatable="yes">Search conversations</property> + <style> + <class name="dino-conversation-search-entry"/> + </style> + </object> + </child> </object> </child> <property name="content"> @@ -87,7 +91,7 @@ <property name="width_request">260</property> <property name="title" translatable="yes">You have no open chats</property> - <property name="description" translatable="yes">Click + to start a chat or join a channel</property> + <property name="description" translatable="yes">Use the menu to start a chat or join a channel</property> </object> </property> </object> @@ -108,8 +112,22 @@ <object class="AdwToolbarView"> <child type="top"> <object class="AdwHeaderBar" id="conversation_headerbar"> + <property name="show-start-title-buttons">False</property> + <property name="show-end-title-buttons">False</property> + <style> + <class name="dino-chat-headerbar"/> + </style> <property name="title-widget"> - <object class="AdwWindowTitle" id="conversation_window_title" /> + <object class="GtkButton" id="conversation_title_button"> + <property name="sensitive">False</property> + <property name="tooltip-text" translatable="yes">Conversation Details</property> + <style> + <class name="flat"/> + </style> + <property name="child"> + <object class="AdwWindowTitle" id="conversation_window_title" /> + </property> + </object> </property> </object> </child> @@ -185,6 +203,8 @@ </property> </object> </child> + </object> + </property> </object> </property> </template> diff --git a/main/data/main_window_placeholder.ui b/main/data/main_window_placeholder.ui index 123920fe..b9b72820 100644 --- a/main/data/main_window_placeholder.ui +++ b/main/data/main_window_placeholder.ui @@ -8,11 +8,6 @@ <property name="hexpand">True</property> <property name="vexpand">True</property> <child> - <object class="GtkHeaderBar"> - <property name="show-title-buttons">true</property> - </object> - </child> - <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="valign">center</property> diff --git a/main/data/menu_add.ui b/main/data/menu_add.ui deleted file mode 100644 index c984e081..00000000 --- a/main/data/menu_add.ui +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<interface> - <menu id="menu_add"> - <section> - <item> - <attribute name="action">app.add_chat</attribute> - <attribute name="label" translatable="yes">Start Conversation</attribute> - </item> - <item> - <attribute name="action">app.add_conference</attribute> - <attribute name="label" translatable="yes">Join Channel</attribute> - </item> - </section> - </menu> -</interface> diff --git a/main/data/menu_app.ui b/main/data/menu_app.ui index 9b85634d..c056c1aa 100644 --- a/main/data/menu_app.ui +++ b/main/data/menu_app.ui @@ -3,6 +3,22 @@ <menu id="menu_app"> <section> <item> + <attribute name="action">app.add_chat</attribute> + <attribute name="label" translatable="yes">Start Conversation</attribute> + </item> + <item> + <attribute name="action">app.add_conference</attribute> + <attribute name="label" translatable="yes">Join Channel</attribute> + </item> + </section> + <section> + <item> + <attribute name="action">app.mark_all_as_read</attribute> + <attribute name="label" translatable="yes">Mark all as read</attribute> + </item> + </section> + <section> + <item> <attribute name="action">app.preferences</attribute> <attribute name="label" translatable="yes">Preferences</attribute> </item> diff --git a/main/data/preferences_window/account_preferences_subpage.ui b/main/data/preferences_window/account_preferences_subpage.ui index bb458e74..9860de27 100644 --- a/main/data/preferences_window/account_preferences_subpage.ui +++ b/main/data/preferences_window/account_preferences_subpage.ui @@ -78,6 +78,17 @@ </object> </child> <child> + <object class="AdwActionRow"> + <property name="title" translatable="yes">Conversation color</property> + <property name="subtitle" translatable="yes">Used to distinguish this account in the conversation list</property> + <child type="suffix"> + <object class="GtkBox" id="account_color_box"> + <property name="valign">center</property> + </object> + </child> + </object> + </child> + <child> <object class="AdwActionRow" id="password_change"> <property name="title" translatable="yes">Password</property> <child type="suffix"> diff --git a/main/data/preferences_window/general_preferences_page.ui b/main/data/preferences_window/general_preferences_page.ui index ca240511..c8d78654 100644 --- a/main/data/preferences_window/general_preferences_page.ui +++ b/main/data/preferences_window/general_preferences_page.ui @@ -27,13 +27,63 @@ </child> <child> <object class="AdwPreferencesGroup"> + <property name="title" translatable="yes">Notifications</property> <child> - <object class="AdwSwitchRow" id="notification_row"> - <property name="title" translatable="yes">_Notifications</property> + <object class="AdwSwitchRow" id="notification_badge_row"> + <property name="title" translatable="yes">Notification _Badge</property> + <property name="subtitle" translatable="yes">Show the unread count on the application icon</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_messages_row"> + <property name="title" translatable="yes">_Messages</property> <property name="subtitle" translatable="yes">Notify when a new message arrives</property> <property name="use-underline">True</property> </object> </child> + <child> + <object class="AdwSwitchRow" id="notification_files_row"> + <property name="title" translatable="yes">_Files</property> + <property name="subtitle" translatable="yes">Notify when a new file arrives</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_calls_row"> + <property name="title" translatable="yes">_Calls</property> + <property name="subtitle" translatable="yes">Notify about incoming calls</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_subscription_requests_row"> + <property name="title" translatable="yes">_Subscription Requests</property> + <property name="subtitle" translatable="yes">Notify when someone wants to add you as a contact</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_group_chat_invitations_row"> + <property name="title" translatable="yes">_Group Chat Invitations</property> + <property name="subtitle" translatable="yes">Notify when someone invites you to a group chat</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_permission_requests_row"> + <property name="title" translatable="yes">_Permission Requests</property> + <property name="subtitle" translatable="yes">Notify when someone requests permission to write in a group chat</property> + <property name="use-underline">True</property> + </object> + </child> + <child> + <object class="AdwSwitchRow" id="notification_connection_errors_row"> + <property name="title" translatable="yes">Connection _Errors</property> + <property name="subtitle" translatable="yes">Notify when an account cannot connect</property> + <property name="use-underline">True</property> + </object> + </child> </object> </child> <child> diff --git a/main/data/preferences_window/preferences_dialog.ui b/main/data/preferences_window/preferences_dialog.ui index 3a35b63f..dae1ce7c 100644 --- a/main/data/preferences_window/preferences_dialog.ui +++ b/main/data/preferences_window/preferences_dialog.ui @@ -28,5 +28,13 @@ <property name="model" bind-source="model" bind-property="general-page" bind-flags="sync-create" /> </object> </child> + <child> + <object class="DinoUiTweaksPreferencesPage" id="tweaks_page"> + <property name="title" translatable="yes">Tweaks</property> + <property name="name">tweaks</property> + <property name="icon-name">preferences-desktop-appearance-symbolic</property> + <property name="model" bind-source="model" bind-property="tweaks-page" bind-flags="sync-create" /> + </object> + </child> </template> -</interface>
\ No newline at end of file +</interface> diff --git a/main/data/preferences_window/tweaks_preferences_page.ui b/main/data/preferences_window/tweaks_preferences_page.ui new file mode 100644 index 00000000..6d011b46 --- /dev/null +++ b/main/data/preferences_window/tweaks_preferences_page.ui @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk" version="4.0"/> + <object class="GtkAdjustment" id="avatar_roundness_adjustment"> + <property name="lower">0</property> + <property name="upper">100</property> + <property name="step-increment">1</property> + <property name="page-increment">10</property> + </object> + <object class="GtkTextBuffer" id="custom_css_buffer"/> + <template class="DinoUiTweaksPreferencesPage" parent="AdwPreferencesPage"> + <child> + <object class="AdwPreferencesGroup"> + <property name="title" translatable="yes">Appearance</property> + <child> + <object class="AdwSpinRow" id="avatar_roundness_row"> + <property name="title" translatable="yes">Profile Picture Roundness (%)</property> + <property name="subtitle" translatable="yes">Set to 0 for square pictures or 100 for circular pictures</property> + <property name="adjustment">avatar_roundness_adjustment</property> + <property name="digits">0</property> + <property name="numeric">True</property> + <property name="snap-to-ticks">True</property> + </object> + </child> + </object> + </child> + <child> + <object class="AdwPreferencesGroup"> + <property name="title" translatable="yes">Custom CSS</property> + <property name="description" translatable="yes">Apply custom styles to the application</property> + <child> + <object class="GtkScrolledWindow"> + <property name="height-request">180</property> + <property name="has-frame">True</property> + <property name="child"> + <object class="GtkTextView"> + <property name="buffer">custom_css_buffer</property> + <property name="monospace">True</property> + <property name="wrap-mode">word-char</property> + <property name="top-margin">12</property> + <property name="bottom-margin">12</property> + <property name="left-margin">12</property> + <property name="right-margin">12</property> + <property name="accepts-tab">True</property> + <accessibility> + <property name="label" translatable="yes">Custom CSS</property> + </accessibility> + </object> + </property> + </object> + </child> + </object> + </child> + </template> +</interface> diff --git a/main/data/style.css b/main/data/style.css index 2c6d3d03..7038f901 100644 --- a/main/data/style.css +++ b/main/data/style.css @@ -9,18 +9,90 @@ statuspage { opacity: 0.5; } -window.dino-main .dino-header-right { - background: @theme_base_color; +window.dino-main, +window.dino-main > *, +window.dino-main navigation-split-view, +window.dino-main navigation-split-view > .sidebar-pane, +window.dino-main navigation-split-view > .content-pane, +window.dino-main toolbarview, +window.dino-main .dino-conversation { + margin: 0; + padding: 0; } -window.dino-main .dino-header-left { - background: @insensitive_bg_color; +window.dino-main { + outline: none; } -window.dino-main headerbar.dino-left label.title { - opacity: 0; - font-size: 0; - color: transparent; +window.dino-main headerbar.dino-chat-headerbar, +window.dino-main .dino-conversation-list-toolbar { + min-height: 44px; + margin: 0; + background-color: @window_bg_color; + background-image: none; +} + +window.dino-main .dino-conversation-list-toolbar { + border-bottom: none; + box-shadow: 0 3px 12px alpha(black, 0.18); +} + +window.dino-main headerbar.dino-chat-headerbar { + border-bottom: none; + box-shadow: 0 3px 12px alpha(black, 0.18); +} + +window.dino-main headerbar.dino-chat-headerbar windowtitle { + min-height: 2.5em; +} + +window.dino-main headerbar.dino-chat-headerbar > windowhandle > box, +window.dino-main .dino-conversation-list-toolbar { + padding-top: 0; + padding-bottom: 0; +} + +window.dino-main .dino-conversation-list-toolbar { + padding-left: 6px; + padding-right: 6px; +} + +window.dino-main headerbar button, +window.dino-main .dino-conversation-list-toolbar entry, +window.dino-main .dino-conversation-list-toolbar button, +window.dino-main .navigation-sidebar > row, +window.dino-main box.dino-chatinput, +window.dino-main box.dino-chatinput button, +window.dino-main popover > contents, +window.dino-main popover row { + border-radius: 0; +} + +window.dino-main .dino-conversation-list-toolbar entry.dino-conversation-search-entry { + border-radius: 6px; +} + +window.dino-main button.circular, +window.dino-main box.dino-chatinput button.circular { + border-radius: 999em; +} + +window.dino-main .navigation-sidebar { + margin: 0; + padding: 0; +} + +window.dino-main .navigation-sidebar > row { + margin: 0; +} + +window.dino-main navigation-split-view > .sidebar-pane, +window.dino-main navigation-split-view > .content-pane { + margin: 0; +} + +window.dino-main navigation-split-view > .content-pane { + border-left: 1px solid color-mix(in srgb, @window_bg_color 92%, @theme_fg_color); } window.dino-main .dino-conversation undershoot, @@ -97,7 +169,7 @@ window.dino-main .dino-conversation .message-box.edit-mode:hover { } window.dino-main .dino-conversation .message-box.edit-mode frame { - border-radius: 3px; + border-radius: 0; padding: 0 7px; } @@ -120,15 +192,47 @@ window.dino-main .dino-quote:hover { background: alpha(@theme_fg_color, 0.08); } +window.dino-main .code-block { + background: alpha(@theme_fg_color, 0.06); + border: 1px solid alpha(@theme_fg_color, 0.12); + border-radius: 0; +} + +window.dino-main .code-block-header { + background: alpha(@theme_fg_color, 0.06); + border-bottom: 1px solid alpha(@theme_fg_color, 0.1); + padding: 4px 6px 4px 12px; +} + +window.dino-main .code-block-language { + font-weight: bold; +} + +window.dino-main .code-block-meta { + color: alpha(@theme_fg_color, 0.65); + font-size: 0.85em; +} + +window.dino-main .code-block-download { + min-height: 24px; + min-width: 24px; + padding: 0; +} + +window.dino-main .code-block-content { + font-family: monospace; + padding: 10px 12px 12px; +} + picture.avatar { - border-radius: 3px; + border-radius: 0; } /* Overlay Toolbar */ .compact-card-toolbar { padding: 2px; - border-radius: 6px; + border-radius: 0; border-spacing: 0; } @@ -137,12 +241,30 @@ picture.avatar { margin-bottom: 0; } +window.dino-main .message-actions { + padding: 1px; + border-radius: 999em; +} + +window.dino-main .message-actions > button, +window.dino-main .message-actions > menubutton > button { + min-width: 28px; + min-height: 28px; + margin: 0; + padding: 0; + border-radius: 999em; +} + +window.dino-main .message-text { + caret-color: transparent; +} + /* File Widget */ window.dino-main .file-box-outer, window.dino-main .call-box-outer { background: @theme_base_color; - border-radius: 8px; + border-radius: 0; border: 1px solid var(--border-color); } @@ -151,9 +273,14 @@ window.dino-main .call-box { margin: 12px 16px 12px 12px; } -window.dino-main .file-image-widget { +window.dino-main .file-image-widget, +window.dino-main .file-video-widget { border: 1px solid alpha(@theme_fg_color, 0.1); - border-radius: 6px; + border-radius: 0; +} + +window.dino-main .file-video-widget { + background: black; } window.dino-main .file-image-widget .file-box-outer { @@ -173,13 +300,13 @@ window.dino-main .file-image-widget .file-box-outer button:hover { } .dino-main .file-image-widget picture { - border-radius: 6px; + border-radius: 0; } .dino-main .file-details { color: white; background: alpha(black, 0.3); - border-radius: 5px; + border-radius: 0; padding: 5px 10px; } @@ -214,6 +341,7 @@ window.dino-main .multiparty-participants { min-height: 16px; min-width: 30px; padding: 4px; + border-radius: 999em; } .dino-main .reaction-grid button.own-reaction, @@ -263,22 +391,37 @@ image.dino-status-online, image.dino-status-chat { background-color: var(--shade-color); opacity: 80%; padding: 12px; - border-radius: 6px 6px 0 0; + border-radius: 0; } /* Chat Input*/ -/* These rules are direved from the libadwaita style for activateable entry rows */ -window.dino-main box.dino-chatinput.activatable { - outline: 0 solid transparent; - outline-offset: 5px; - transition: outline-color 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), outline-width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), outline-offset 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-image 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), border-radius 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); +window.dino-main box.dino-chatinput { + margin: 0; + padding: 2px; + background-color: shade(@view_bg_color, 0.96); + background-image: none; + border: none; + box-shadow: 0 -3px 12px alpha(black, 0.18); + outline: none; +} + +window.dino-main box.dino-chatinput button.dino-attach-button { + background-image: none; + border: none; + box-shadow: none; + outline: none; +} + +window.dino-main box.dino-chatinput > separator { + min-width: 0; + background: none; + border: none; } -window.dino-main box.dino-chatinput.activatable:focus-within { - outline-offset: -1px; - outline-width: 2px; - outline-color: color-mix(in srgb, var(--accent-color) 50%, transparent); +.dino-chatinput textview, +.dino-chatinput textview text { + background: none; } .dino-unread-line label { @@ -290,7 +433,7 @@ window.dino-main box.dino-chatinput.activatable:focus-within { } window.dino-main .image-round-corners picture { - border-radius: 8px; + border-radius: 0; } window.dino-main .file-remove-button { @@ -422,7 +565,7 @@ box.dino-input-error .chat-input-status.input-status-highlight-once { .dino-call-window .text-no-controls { color: black; background: white; - border-radius: 5px; + border-radius: 0; padding: 5px 10px; } diff --git a/main/meson.build b/main/meson.build index d9f56e81..b733e0c8 100644 --- a/main/meson.build +++ b/main/meson.build @@ -41,6 +41,8 @@ sources = files( 'src/ui/contact_details/settings_provider.vala', 'src/ui/conversation_content_view/call_widget.vala', 'src/ui/conversation_content_view/chat_state_populator.vala', + 'src/ui/conversation_content_view/syntax_highlighting.vala', + 'src/ui/conversation_content_view/code_block_widget.vala', 'src/ui/conversation_content_view/content_populator.vala', 'src/ui/conversation_content_view/conversation_item_skeleton.vala', 'src/ui/conversation_content_view/conversation_view.vala', @@ -50,6 +52,7 @@ sources = files( 'src/ui/conversation_content_view/file_transmission_progress.c', 'src/ui/conversation_content_view/file_transmission_progress.vala', 'src/ui/conversation_content_view/file_group_widget.vala', + 'src/ui/conversation_content_view/file_video_widget.vala', 'src/ui/conversation_content_view/file_widget.vala', 'src/ui/conversation_content_view/item_actions.vala', 'src/ui/conversation_content_view/message_widget.vala', @@ -67,6 +70,7 @@ sources = files( 'src/ui/conversation_view.vala', 'src/ui/conversation_view_controller.vala', 'src/ui/global_search.vala', + 'src/ui/launcher_badge.vala', 'src/ui/main_window.vala', 'src/ui/main_window_controller.vala', 'src/ui/notifier_freedesktop.vala', @@ -74,6 +78,8 @@ sources = files( 'src/ui/occupant_menu/list.vala', 'src/ui/occupant_menu/list_row.vala', 'src/ui/occupant_menu/view.vala', + 'src/ui/status_notifier_item.vala', + 'src/ui/status_notifier_menu.vala', 'src/ui/util/accounts_combo_box.vala', 'src/ui/util/config.vala', 'src/ui/util/data_forms.vala', @@ -98,6 +104,7 @@ sources = files( 'src/windows/preferences_window/encryption_preferences_page.vala', 'src/windows/preferences_window/general_preferences_page.vala', 'src/windows/preferences_window/preferences_dialog.vala', + 'src/windows/preferences_window/tweaks_preferences_page.vala', 'src/windows/conversation_details.vala', ) sources += gnome.compile_resources( diff --git a/main/src/main.vala b/main/src/main.vala index 63c553fc..65e9d250 100644 --- a/main/src/main.vala +++ b/main/src/main.vala @@ -14,6 +14,7 @@ void main(string[] args) { Intl.textdomain(GETTEXT_PACKAGE); internationalize(GETTEXT_PACKAGE, search_path_generator.get_locale_path(GETTEXT_PACKAGE, LOCALE_INSTALL_DIR)); + Environment.set_variable("GTK_CSD", "0", true); Gtk.init(); Dino.Ui.Application app = new Dino.Ui.Application() { search_path_generator=search_path_generator }; Plugins.Loader loader = new Plugins.Loader(app); diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 83290743..cb3d52e4 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -14,6 +14,9 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { public MainWindow window; public MainWindowController controller; + private LauncherBadge? launcher_badge; + private StatusNotifierItem? status_notifier_item; + private CssProvider custom_css_provider; public Database db { get; set; } public Dino.Entities.Settings settings { get; set; } @@ -31,6 +34,13 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { public Application() throws Error { Object(application_id: "im.dino.Dino", flags: ApplicationFlags.HANDLES_OPEN); init(); + custom_css_provider = new CssProvider(); + settings.notify["custom-css"].connect(update_custom_css); + update_custom_css(); + Gdk.Display? display = Gdk.Display.get_default(); + if (display != null) { + StyleContext.add_provider_for_display(display, custom_css_provider, STYLE_PROVIDER_PRIORITY_USER); + } Environment.set_application_name("Dino"); Window.set_default_icon_name("im.dino.Dino"); @@ -43,6 +53,18 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { Process.exit(0); } + try { + status_notifier_item = new StatusNotifierItem(this); + } catch (IOError e) { + warning("Could not create Dino's tray icon: %s", e.message); + } + + try { + launcher_badge = new LauncherBadge(stream_interactor); + } catch (IOError e) { + warning("Could not create Dino's launcher badge: %s", e.message); + } + NotificationEvents notification_events = stream_interactor.get_module(NotificationEvents.IDENTITY); get_notifications_dbus.begin((_, res) => { // It might take a bit to get the interface. NotificationEvents will queue any notifications in the meantime. @@ -68,18 +90,37 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { stream_interactor.get_module(FileManager.IDENTITY).add_metadata_provider(new Util.AudioVideoFileMetadataProvider()); }); + shutdown.connect(() => { + if (launcher_badge != null) { + launcher_badge.shutdown(); + launcher_badge = null; + } + if (status_notifier_item != null) { + status_notifier_item.shutdown(); + status_notifier_item = null; + } + }); + activate.connect(() => { if (window == null) { controller = new MainWindowController(this, stream_interactor, db); config = new Config(db); window = new MainWindow(this, stream_interactor, db, config); controller.set_window(window); - if ((get_flags() & ApplicationFlags.IS_SERVICE) == ApplicationFlags.IS_SERVICE) window.hide_on_close = true; + window.hide_on_close = true; } window.present(); }); } + private void update_custom_css() { +#if GTK_4_12 + custom_css_provider.load_from_string(settings.custom_css); +#else + custom_css_provider.load_from_data(settings.custom_css.data); +#endif + } + public void handle_uri(string jid, string query, Gee.Map<string, string> options) { switch (query) { case "join": @@ -129,6 +170,12 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { about_action.activate.connect(show_about_window); add_action(about_action); + SimpleAction mark_all_as_read_action = new SimpleAction("mark_all_as_read", null); + mark_all_as_read_action.activate.connect(() => { + stream_interactor.get_module(ChatInteraction.IDENTITY).mark_all_as_read.begin(); + }); + add_action(mark_all_as_read_action); + SimpleAction quit_action = new SimpleAction("quit", null); quit_action.activate.connect(quit); add_action(quit_action); @@ -316,4 +363,3 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { dialog.present(); } } - diff --git a/main/src/ui/conversation_content_view/code_block_widget.vala b/main/src/ui/conversation_content_view/code_block_widget.vala new file mode 100644 index 00000000..3b24c944 --- /dev/null +++ b/main/src/ui/conversation_content_view/code_block_widget.vala @@ -0,0 +1,225 @@ +using Gee; +using Gdk; +using Gtk; +using Pango; + +namespace Dino.Ui.ConversationSummary { + +public class CodeBlockPart : Object { + public bool is_code; + public string text; + public string language; + public int source_start_char; + + public CodeBlockPart.from_text(string text, int source_start_char) { + this.text = text; + this.source_start_char = source_start_char; + this.language = ""; + } + + public CodeBlockPart.from_code(string text, string language) { + this.is_code = true; + this.text = text; + this.language = language; + } +} + +public class CodeBlockParser : Object { + private static Regex? fence_regex; + + public static ArrayList<CodeBlockPart> parse(string text, out bool has_blocks) { + var parts = new ArrayList<CodeBlockPart>(); + Regex regex = get_fence_regex(); + MatchInfo match_info; + int offset = 0; + has_blocks = false; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + parts.add(new CodeBlockPart.from_text(text, 0)); + return parts; + } + while (match_info.matches()) { + int match_start; + int match_end; + int code_start; + int code_end; + match_info.fetch_pos(0, out match_start, out match_end); + match_info.fetch_pos(2, out code_start, out code_end); + int text_end = match_start; + if (text_end > offset && text[text_end - 1] == '\n') { + text_end--; + if (text_end > offset && text[text_end - 1] == '\r') text_end--; + } + if (text_end > offset) { + parts.add(new CodeBlockPart.from_text(text.substring(offset, text_end - offset), text.substring(0, offset).char_count())); + } + if (code_end > code_start && text[code_end - 1] == '\n') { + code_end--; + if (code_end > code_start && text[code_end - 1] == '\r') code_end--; + } + string code = text.substring(code_start, code_end - code_start).replace("\r\n", "\n"); + parts.add(new CodeBlockPart.from_code(code, match_info.fetch(1).strip())); + has_blocks = true; + offset = match_end; + if (offset >= text.length) break; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + break; + } + } + if (offset < text.length) { + parts.add(new CodeBlockPart.from_text(text.substring(offset), text.substring(0, offset).char_count())); + } + return parts; + } + + private static Regex get_fence_regex() { + if (fence_regex == null) { + try { + fence_regex = new Regex("(?ms)^```[\\t ]*([^\\r\\n`]*)[\\t ]*\\r?\\n(.*?)^```[\\t ]*(?:\\r?\\n|$)", RegexCompileFlags.OPTIMIZE); + } catch (RegexError error) { + GLib.error("Invalid code block expression: %s", error.message); + } + } + return fence_regex; + } +} + +public class CodeBlockWidget : Box { + private string code; + private CodeSyntax.LanguageDefinition? definition; + private Label code_label; + private ulong realize_id; + + public CodeBlockWidget(string code, string language) { + Object(orientation: Orientation.VERTICAL, spacing: 0, halign: Align.START, overflow: Overflow.HIDDEN); + this.code = code; + definition = CodeSyntax.find(language); + add_css_class("code-block"); + + var header = new Box(Orientation.HORIZONTAL, 8); + header.add_css_class("code-block-header"); + + string language_name = definition != null ? definition.name : language.strip(); + if (language_name == "") language_name = _("Plain text"); + var language_label = new Label(language_name) { xalign=0, hexpand=true }; + language_label.add_css_class("code-block-language"); + header.append(language_label); + + int line_count = code == "" ? 0 : code.split("\n").length; + var line_label = new Label(n("%d line", "%d lines", line_count).printf(line_count)); + line_label.add_css_class("code-block-meta"); + header.append(line_label); + + var download_button = new Button.from_icon_name("dino-file-download-symbolic") { + tooltip_text=_("Download code") + }; + download_button.add_css_class("flat"); + download_button.add_css_class("code-block-download"); + download_button.clicked.connect(save); + header.append(download_button); + append(header); + + code_label = new Label(code) { + use_markup=false, + selectable=true, + xalign=0, + yalign=0, + wrap=false + }; + code_label.add_css_class("message-text"); + code_label.add_css_class("code-block-content"); + append(code_label); + + apply_highlighting(); + realize_id = realize.connect(apply_highlighting); + } + + private void apply_highlighting() { + var attributes = new AttrList(); + if (definition != null) { + foreach (CodeSyntax.HighlightSpan span in definition.highlight(code)) { + RGBA color = get_token_color(span.style); + Attribute foreground = Pango.attr_foreground_new( + (uint16) Math.round(color.red * uint16.MAX), + (uint16) Math.round(color.green * uint16.MAX), + (uint16) Math.round(color.blue * uint16.MAX) + ); + foreground.start_index = span.start; + foreground.end_index = span.end; + attributes.insert(foreground.copy()); + if (color.alpha < 1) { + Attribute alpha = Pango.attr_foreground_alpha_new((uint16) Math.round(color.alpha * uint16.MAX)); + alpha.start_index = span.start; + alpha.end_index = span.end; + attributes.insert(alpha.copy()); + } + } + } + code_label.attributes = attributes; + } + + private RGBA get_token_color(CodeSyntax.TokenStyle style) { + StyleContext context = code_label.get_style_context(); + RGBA color = context.get_color(); + string? color_name = null; + switch (style) { + case CodeSyntax.TokenStyle.COMMENT: + color.alpha = 0.62f; + break; + case CodeSyntax.TokenStyle.CONSTANT: + color_name = "error_color"; + break; + case CodeSyntax.TokenStyle.NUMBER: + case CodeSyntax.TokenStyle.PREPROCESSOR: + color_name = "warning_color"; + break; + case CodeSyntax.TokenStyle.STRING: + case CodeSyntax.TokenStyle.TYPE: + color_name = "success_color"; + break; + case CodeSyntax.TokenStyle.FUNCTION: + case CodeSyntax.TokenStyle.KEYWORD: + color_name = "accent_color"; + break; + } + if (color_name != null) { + RGBA theme_color; + if (context.lookup_color(color_name, out theme_color)) color = theme_color; + } + return color; + } + + private void save() { + var dialog = new FileChooserNative(_("Save code as…"), get_root() as Gtk.Window, FileChooserAction.SAVE, _("Save"), _("Cancel")); + dialog.modal = true; + dialog.set_current_name("code." + (definition != null ? definition.extension : "txt")); + dialog.response.connect((response_id) => { + if (response_id == ResponseType.ACCEPT) { + File? target = dialog.get_file(); + if (target != null) { + try { + string? etag; + target.replace_contents(code.data, null, false, FileCreateFlags.REPLACE_DESTINATION, out etag); + } catch (Error error) { + warning("Failed to save code: %s", error.message); + } + } + } + dialog.destroy(); + }); + dialog.show(); + } + + public override void dispose() { + if (realize_id != 0) { + disconnect(realize_id); + realize_id = 0; + } + base.dispose(); + } +} + +} diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index 6a686f1b..83e86827 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -55,6 +55,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug Widget currently_highlighted = null; ContentMetaItem? current_meta_item = null; double last_y = -1; + private bool message_text_selectable = true; private Button create_action_button(string action_name) { var button = new Button() { name=action_name }; @@ -82,6 +83,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug create_action_button("reply"); create_action_button("delete"); + create_action_button("copy"); var menu_button = new MenuButton() { name="menu", tooltip_text=_("More actions") }; action_buttons["menu"] = menu_button; @@ -127,7 +129,10 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // reaches the overlapping part of a button. EventControllerMotion main_motion_events = new EventControllerMotion(); main.add_controller(main_motion_events); - main_motion_events.motion.connect(update_highlight); + main_motion_events.motion.connect((x, y) => { + set_message_text_selectable(true); + update_highlight(x, y); + }); // Process touch events and capture phase to allow highlighting a message without cursor GestureClick click_controller = new GestureClick(); @@ -135,12 +140,23 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug click_controller.propagation_phase = Gtk.PropagationPhase.CAPTURE; main_wrap_box.add_controller(click_controller); click_controller.pressed.connect_after((n, x, y) => { + set_message_text_selectable(false); update_highlight(x, y); }); return this; } + private void set_message_text_selectable(bool selectable) { + if (message_text_selectable == selectable) return; + + message_text_selectable = selectable; + foreach (Plugins.MetaConversationItem item in meta_items) { + MessageMetaItem? message_item = item as MessageMetaItem; + if (message_item != null) message_item.set_text_selectable(selectable); + } + } + public void activate_last_message_correction() { Gee.BidirIterator<Plugins.MetaConversationItem> iter = content_items.bidir_iterator(); iter.last(); @@ -578,6 +594,9 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug private Widget insert_new(Plugins.MetaConversationItem item) { Plugins.MetaConversationItem? lower_item = meta_items.lower(item); + MessageMetaItem? message_item = item as MessageMetaItem; + if (message_item != null) message_item.set_text_selectable(message_text_selectable); + // Fill datastructure ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item); item_item_skeletons[item] = item_skeleton; diff --git a/main/src/ui/conversation_content_view/file_video_widget.vala b/main/src/ui/conversation_content_view/file_video_widget.vala new file mode 100644 index 00000000..703428d9 --- /dev/null +++ b/main/src/ui/conversation_content_view/file_video_widget.vala @@ -0,0 +1,174 @@ +using Gtk; + +using Dino.Entities; + +namespace Dino.Ui { + +public class FileVideoWidget : Widget { + private const int MIN_WIDTH = 100; + private const int MIN_HEIGHT = 100; + private const int MAX_WIDTH = 600; + private const int MAX_HEIGHT = 300; + + private Overlay overlay = new Overlay(); + private FixedRatioVideo video = new FixedRatioVideo() { + min_width = MIN_WIDTH, + min_height = MIN_HEIGHT, + max_width = MAX_WIDTH, + max_height = MAX_HEIGHT + }; + private Gtk.Box video_overlay_toolbar = new Gtk.Box(Orientation.VERTICAL, 0) { + halign = Align.END, + valign = Align.START, + margin_top = 10, + margin_start = 10, + margin_end = 10, + margin_bottom = 10, + vexpand = false, + visible = false + }; + + construct { + layout_manager = new BinLayout(); + check_widget_leak(this); + } + + public FileVideoWidget(FileTransfer file_transfer) { + this.halign = Align.START; + this.add_css_class("file-video-widget"); + + MenuButton button = new MenuButton(); + button.icon_name = "dino-view-more"; + Menu menu_model = new Menu(); + menu_model.append(_("Open"), "file.open"); + menu_model.append(_("Save as…"), "file.save_as"); + Gtk.PopoverMenu popover_menu = new Gtk.PopoverMenu.from_model(menu_model); + button.popover = popover_menu; + video_overlay_toolbar.append(button); + video_overlay_toolbar.add_css_class("card"); + video_overlay_toolbar.add_css_class("toolbar"); + video_overlay_toolbar.add_css_class("compact-card-toolbar"); + video_overlay_toolbar.set_cursor_from_name("default"); + + video.file = file_transfer.get_file(); + + overlay.set_child(video); + overlay.add_overlay(video_overlay_toolbar); + overlay.set_clip_overlay(video_overlay_toolbar, true); + overlay.insert_after(this, null); + + EventControllerMotion motion_events = new EventControllerMotion(); + this.add_controller(motion_events); + motion_events.enter.connect(() => { + video_overlay_toolbar.visible = true; + }); + motion_events.leave.connect(() => { + if (button.popover != null && button.popover.visible) return; + video_overlay_toolbar.visible = false; + }); + } + + public static bool can_display(FileTransfer file_transfer) { + if (file_transfer.state != FileTransfer.State.COMPLETE || file_transfer.path == null || file_transfer.content_type == null) { + return false; + } + + string? mime_type = file_transfer.content_type.get_mime_type(); + return mime_type != null && mime_type.has_prefix("video/"); + } + + public override void dispose() { + if (video.media_stream != null) video.media_stream.pause(); + if (overlay != null && overlay.parent != null) overlay.unparent(); + base.dispose(); + } +} + +private class FixedRatioVideo : Widget { + public int min_width { get; set; default = -1; } + public int max_width { get; set; default = int.MAX; } + public int min_height { get; set; default = -1; } + public int max_height { get; set; default = int.MAX; } + public File? file { get { return inner.file; } set { inner.file = value; } } + public MediaStream? media_stream { get { return inner.media_stream; } } + + private Gtk.Video inner = new Gtk.Video() { + autoplay = false, + loop = false + }; + + construct { + set_css_name("video"); + inner.insert_after(this, null); + this.notify.connect(queue_resize); + check_widget_leak(this); + } + + private void measure_target_size(out int width, out int height) { + if (width_request != -1 && height_request != -1) { + width = width_request; + height = height_request; + return; + } + + width = min_width; + height = min_height; + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.HORIZONTAL, -1, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + width = int.max(child_nat, width); + } + width = int.min(width, max_width); + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.VERTICAL, width, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + height = int.max(child_nat, height); + } + + if (height > max_height) { + height = max_height; + width = min_width; + + if (inner.should_layout()) { + int child_min, child_nat, child_min_baseline, child_nat_baseline; + inner.measure(Orientation.HORIZONTAL, max_height, out child_min, out child_nat, out child_min_baseline, out child_nat_baseline); + width = int.max(child_nat, width); + } + width = int.min(width, max_width); + } + } + + public override void measure(Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline) { + minimum_baseline = -1; + natural_baseline = -1; + + int width, height; + measure_target_size(out width, out height); + if (orientation == Orientation.HORIZONTAL) { + minimum = min_width; + natural = int.max(min_width, int.min(width, max_width)); + } else if (for_size == -1) { + minimum = min_height; + natural = int.max(min_height, int.min(height, max_height)); + } else { + minimum = natural = int.max(min_height, int.min(height * for_size / width, height)); + } + } + + public override void size_allocate(int width, int height, int baseline) { + if (inner.should_layout()) inner.allocate(width, height, baseline, null); + } + + public override SizeRequestMode get_request_mode() { + return SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public override void dispose() { + inner.unparent(); + base.dispose(); + } +} + +} diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index 04185343..d6d2415a 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -46,6 +46,7 @@ public class FileWidget : SizeRequestBin { enum State { IMAGE, + VIDEO, DEFAULT } @@ -77,6 +78,7 @@ public class FileWidget : SizeRequestBin { private async void update_widget() { bool show_image = FileImageWidget.can_display(file_transfer); + bool show_video = FileVideoWidget.can_display(file_transfer); if (show_image && state != State.IMAGE) { var content_bak = content; @@ -97,7 +99,19 @@ public class FileWidget : SizeRequestBin { } catch (Error e) { } } - if (!show_image && state != State.DEFAULT) { + if (show_image && state == State.IMAGE) return; + + if (show_video && state != State.VIDEO) { + if (content != null) content.unparent(); + content = new FileVideoWidget(file_transfer); + state = State.VIDEO; + content.insert_after(this, null); + return; + } + + if (show_video && state == State.VIDEO) return; + + if (state != State.DEFAULT) { if (content != null) content.unparent(); FileDefaultWidget default_file_widget = new FileDefaultWidget(); default_widget_controller = new FileDefaultWidgetController(default_file_widget); diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 25d3775b..c5a9c145 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -30,12 +30,16 @@ public class MessageMetaItem : ContentMetaItem { 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 }; + private Widget? content_widget; + private bool text_selectable = true; public MessageMetaItem(ContentItem content_item, StreamInteractor stream_interactor) { base(content_item); message_item = content_item as MessageItem; this.stream_interactor = stream_interactor; + label.add_css_class("message-text"); + stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.connect(on_updated_item); stream_interactor.get_module(MessageDeletion.IDENTITY).item_deleted.connect(on_updated_item); @@ -73,111 +77,176 @@ public class MessageMetaItem : ContentMetaItem { update_label(); } - private void generate_markup_text(ContentItem item, Label label) { - MessageItem message_item = item as MessageItem; - Conversation conversation = message_item.conversation; + private void generate_markup_text(Label label, string text, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { Message message = message_item.message; + label.set_attributes(create_text_attributes(text, 0, markups, action_name_length)); - // Get a copy of the markup spans, such that we can modify them - var markups = new ArrayList<Xep.MessageMarkup.Span>(); - 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 }); + bool theme_dependent; + string markup_text = parse_text_markup(label, text, out theme_dependent); + + 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"; + markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>"; } - string markup_text = message.body; + string dim_color = Util.is_dark_theme(label) ? "#BDBDBD" : "#707070"; - var attrs = new AttrList(); - label.set_attributes(attrs); + if (message.body == "") { + markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted")); + theme_dependent = true; + } + markup_text += get_meta_markup(label, true, ref theme_dependent); - if (markup_text == null) return; // TODO remove + if (theme_dependent && realize_id == -1 && !label.get_realized()) { + realize_id = label.realize.connect(update_label); + } else if (!theme_dependent && realize_id != -1) { + label.disconnect(realize_id); + } + label.label = markup_text; + } - // Only process messages up to a certain size - if (markup_text.length > 10000) { - markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; + private ArrayList<Xep.MessageMarkup.Span> copy_markups(Message message) { + var markups = new ArrayList<Xep.MessageMarkup.Span>(); + 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 }); } + return markups; + } - bool theme_dependent = false; + private string prepare_message(ArrayList<Xep.MessageMarkup.Span> markups, out int action_name_length, bool truncate = true) { + Message message = message_item.message; + string text = message.body ?? ""; + action_name_length = 0; - markup_text = Util.remove_fallbacks_adjust_markups(markup_text, message.quoted_item_id > 0, message.get_fallbacks(), markups); + if (truncate && text.char_count() > 10000) { + text = text.substring(0, text.index_of_nth_char(10000)) + " [" + _("Message too long") + "]"; + } - 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); + text = Util.remove_fallbacks_adjust_markups(text, message.quoted_item_id > 0, message.get_fallbacks(), markups); - // 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); + if (text.has_prefix("/me ")) { + string display_name = Util.get_participant_display_name(stream_interactor, message_item.conversation, message.from); + text = display_name + " " + text.substring(4); + action_name_length = display_name.char_count(); foreach (Xep.MessageMarkup.Span span in markups) { - int length = display_name.char_count() - 4 + 1; - span.start_char += length; - span.end_char += length; + int difference = action_name_length - 3; + span.start_char += difference; + span.end_char += difference; } + } + + return text; + } - bold_attr.end_index = display_name.length; - italic_attr.end_index = display_name.length; - attrs.insert(bold_attr.copy()); - attrs.insert(italic_attr.copy()); + private Label create_text_label() { + var text_label = new Label("") { + use_markup=true, + xalign=0, + selectable=text_selectable, + wrap=true, + wrap_mode=Pango.WrapMode.WORD_CHAR, + hexpand=true + }; + text_label.add_css_class("message-text"); + text_label.activate_link.connect(on_label_activate_link); + return text_label; + } + + public void set_text_selectable(bool selectable) { + text_selectable = selectable; + if (content_widget != null) set_text_selectable_recursive(content_widget, selectable); + } + + private void set_text_selectable_recursive(Widget widget, bool selectable) { + Label? text_label = widget as Label; + if (text_label != null && text_label.has_css_class("message-text")) { + text_label.selectable = selectable; + } + + for (Widget? child = widget.get_first_child(); child != null; child = child.get_next_sibling()) { + set_text_selectable_recursive(child, selectable); } + } + + private void add_text_attribute(AttrList attributes, Attribute attribute, string text, int source_start, int span_start, int span_end) { + int source_end = source_start + text.char_count(); + int start = int.max(source_start, span_start); + int end = int.min(source_end, span_end); + if (start >= end) return; + + attribute.start_index = text.index_of_nth_char(start - source_start); + attribute.end_index = text.index_of_nth_char(end - source_start); + attributes.insert(attribute.copy()); + } + private AttrList create_text_attributes(string text, int source_start, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + var attributes = new AttrList(); foreach (var markup in markups) { - foreach (var ty in markup.types) { - Attribute attr = null; - switch (ty) { + foreach (var type in markup.types) { + Attribute attribute; + switch (type) { case Xep.MessageMarkup.SpanType.EMPHASIS: - attr = Pango.attr_style_new(Pango.Style.ITALIC); + attribute = Pango.attr_style_new(Pango.Style.ITALIC); break; case Xep.MessageMarkup.SpanType.STRONG_EMPHASIS: - attr = Pango.attr_weight_new(Pango.Weight.BOLD); + attribute = Pango.attr_weight_new(Pango.Weight.BOLD); break; case Xep.MessageMarkup.SpanType.DELETED: - attr = Pango.attr_strikethrough_new(true); + attribute = Pango.attr_strikethrough_new(true); break; + default: + continue; } - 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()); + add_text_attribute(attributes, (owned) attribute, text, source_start, markup.start_char, markup.end_char); } } - // Work around pango bug - markup_text = Util.unbreak_space_around_non_spacing_mark((owned) markup_text); + if (action_name_length > 0) { + add_text_attribute(attributes, Pango.attr_weight_new(Pango.Weight.BOLD), text, source_start, 0, action_name_length); + add_text_attribute(attributes, Pango.attr_style_new(Pango.Style.ITALIC), text, source_start, 0, action_name_length); + } + + return attributes; + } + private string parse_text_markup(Label text_label, string text, out bool theme_dependent) { + string markup_text = Util.unbreak_space_around_non_spacing_mark(text); + theme_dependent = false; + Conversation conversation = message_item.conversation; if (conversation.type_ == Conversation.Type.GROUPCHAT) { - markup_text = Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent); - } else { - markup_text = Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(this.label), ref theme_dependent); + return Util.parse_add_markup_theme(markup_text, conversation.nickname, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent); } + return Util.parse_add_markup_theme(markup_text, null, true, true, true, Util.is_dark_theme(text_label), ref theme_dependent); + } - 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"; - markup_text = @"<span size=\'$size_str\'>" + markup_text + "</span>"; - } + private void format_text_segment(Label text_label, CodeBlockPart part, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + text_label.set_attributes(create_text_attributes(part.text, part.source_start_char, markups, action_name_length)); + bool theme_dependent; + text_label.label = parse_text_markup(text_label, part.text, out theme_dependent); + } - string dim_color = Util.is_dark_theme(this.label) ? "#BDBDBD" : "#707070"; + private string get_meta_markup(Label meta_label, bool inline, ref bool theme_dependent) { + Message message = message_item.message; + string dim_color = Util.is_dark_theme(meta_label) ? "#BDBDBD" : "#707070"; + string separator = inline ? " " : ""; + string markup = ""; - if (message.body == "") { - markup_text = @"<i><span size='small' color='$dim_color'>%s</span></i>".printf(_("Message deleted")); - theme_dependent = true; - } if (message.edit_to != null) { - markup_text += @" <span size='small' color='$dim_color'>(%s)</span>".printf(_("edited")); + markup = @"$separator<span size='small' color='$dim_color'>(%s)</span>".printf(_("edited")); + separator = " "; theme_dependent = true; } - // Append message status info additional_info = AdditionalInfo.NONE; if (message.direction == Message.DIRECTION_SENT && (message.marked == Message.Marked.SENDING || message.marked == Message.Marked.UNSENT)) { - // Append "pending..." iff message has not been sent yet if (message.time.compare(new DateTime.now_utc().add_seconds(-10)) < 0) { - markup_text += @" <span size='small' color='$dim_color'>%s</span>".printf(_("pending…")); + markup += @"$separator<span size='small' color='$dim_color'>%s</span>".printf(_("pending…")); theme_dependent = true; additional_info = AdditionalInfo.PENDING; } else { int time_diff = (- (int) message.time.difference(new DateTime.now_utc()) / 1000); - if (pending_timeout_id != -1) Source.remove(pending_timeout_id); pending_timeout_id = Timeout.add(10000 - time_diff, () => { update_label(); pending_timeout_id = -1; @@ -185,24 +254,63 @@ public class MessageMetaItem : ContentMetaItem { }); } } else if (message.direction == Message.DIRECTION_SENT && message.marked == Message.Marked.ERROR) { - // Append "delivery failed" if there was a server error - // TODO: Use a dedicated label, not appended text - string error_color = Util.rgba_to_hex(Util.get_label_pango_color(label, "error_color")); - markup_text += " <span size='small' color='%s'>%s</span>".printf(error_color, _("delivery failed")); + string error_color = Util.rgba_to_hex(Util.get_label_pango_color(meta_label, "error_color")); + markup += @"$separator<span size='small' color='$error_color'>%s</span>".printf(_("delivery failed")); theme_dependent = true; additional_info = AdditionalInfo.DELIVERY_FAILED; } - if (theme_dependent && realize_id == -1) { - realize_id = label.realize.connect(update_label); - } else if (!theme_dependent && realize_id != -1) { - label.disconnect(realize_id); + return markup; + } + + private Widget generate_code_widget(ArrayList<CodeBlockPart> parts, ArrayList<Xep.MessageMarkup.Span> markups, int action_name_length) { + var content = new Box(Orientation.VERTICAL, 6) { hexpand=true }; + foreach (CodeBlockPart part in parts) { + if (part.is_code) { + content.append(new CodeBlockWidget(part.text, part.language)); + } else { + Label text_label = create_text_label(); + format_text_segment(text_label, part, markups, action_name_length); + content.append(text_label); + } } - label.label = markup_text; + + Label meta_label = create_text_label(); + bool theme_dependent = false; + string meta_markup = get_meta_markup(meta_label, false, ref theme_dependent); + if (meta_markup != "") { + meta_label.label = meta_markup; + content.append(meta_label); + } + return content; } public void update_label() { - generate_markup_text(content_item, label); + if (pending_timeout_id != -1) { + Source.remove(pending_timeout_id); + pending_timeout_id = -1; + } + if (realize_id != -1) { + label.disconnect(realize_id); + realize_id = -1; + } + + var markups = copy_markups(message_item.message); + int action_name_length; + string text = prepare_message(markups, out action_name_length); + bool has_blocks; + ArrayList<CodeBlockPart> parts = CodeBlockParser.parse(text, out has_blocks); + if (has_blocks) { + content_widget = generate_code_widget(parts, markups, action_name_length); + } else { + generate_markup_text(label, text, markups, action_name_length); + content_widget = label; + } + set_text_selectable_recursive(content_widget, text_selectable); + + if (outer != null && !in_edit_mode) { + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); + } } public override Object? get_widget(Plugins.ConversationItemWidgetInterface outer, Plugins.WidgetType type) { @@ -210,7 +318,7 @@ public class MessageMetaItem : ContentMetaItem { this.notify["in-edit-mode"].connect(on_in_edit_mode_changed); - outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); if (message_item.message.quoted_item_id > 0) { var quoted_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item_by_id(message_item.conversation, message_item.message.quoted_item_id); @@ -223,7 +331,7 @@ public class MessageMetaItem : ContentMetaItem { outer.set_widget(quote_widget, Plugins.WidgetType.GTK4, 1); } } - return label; + return content_widget; } public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) { @@ -244,6 +352,19 @@ public class MessageMetaItem : ContentMetaItem { actions.add(action1); } + Plugins.MessageAction copy_action = new Plugins.MessageAction(); + copy_action.name = "copy"; + copy_action.icon_name = "dino-edit-copy-symbolic"; + copy_action.tooltip = _("Copy message"); + copy_action.callback = () => { + var markups = copy_markups(message_item.message); + int action_name_length; + string text = prepare_message(markups, out action_name_length, false); + Gdk.Display? display = Gdk.Display.get_default(); + if (display != null) display.get_clipboard().set_text(text); + }; + actions.add(copy_action); + actions.add(get_reply_action(content_item, message_item.conversation, stream_interactor)); actions.add(get_reaction_action(content_item, message_item.conversation, stream_interactor)); @@ -267,7 +388,7 @@ public class MessageMetaItem : ContentMetaItem { edit_mode.cancelled.connect(() => { in_edit_mode = false; - outer.set_widget(label, Plugins.WidgetType.GTK4, 2); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); }); edit_mode.send.connect(() => { string text = edit_mode.chat_text_view.text_view.buffer.text; @@ -275,7 +396,7 @@ public class MessageMetaItem : ContentMetaItem { 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); + outer.set_widget(content_widget, Plugins.WidgetType.GTK4, 2); }); edit_mode.chat_text_view.set_text(message); diff --git a/main/src/ui/conversation_content_view/quote_widget.vala b/main/src/ui/conversation_content_view/quote_widget.vala index ce0011e1..98aefacb 100644 --- a/main/src/ui/conversation_content_view/quote_widget.vala +++ b/main/src/ui/conversation_content_view/quote_widget.vala @@ -87,11 +87,17 @@ namespace Dino.Ui.Quote { GestureClick gesture_click_controller = new GestureClick(); outer.add_controller(gesture_click_controller); + bool click_stopped = false; gesture_click_controller.pressed.connect(() => { - model.jump_to(); + click_stopped = false; + }); + gesture_click_controller.stopped.connect(() => { + click_stopped = true; + }); + gesture_click_controller.released.connect(() => { + if (!click_stopped) model.jump_to(); }); return outer; } } - diff --git a/main/src/ui/conversation_content_view/syntax_highlighting.vala b/main/src/ui/conversation_content_view/syntax_highlighting.vala new file mode 100644 index 00000000..b45ba81c --- /dev/null +++ b/main/src/ui/conversation_content_view/syntax_highlighting.vala @@ -0,0 +1,136 @@ +using Gee; + +namespace Dino.Ui.CodeSyntax { + +public enum TokenStyle { + COMMENT, + CONSTANT, + FUNCTION, + KEYWORD, + NUMBER, + PREPROCESSOR, + STRING, + TYPE +} + +public class HighlightSpan : Object { + public int start; + public int end; + public TokenStyle style; + + public HighlightSpan(int start, int end, TokenStyle style) { + this.start = start; + this.end = end; + this.style = style; + } +} + +public class HighlightRule : Object { + public TokenStyle style; + private Regex regex; + + public HighlightRule(TokenStyle style, string pattern) { + this.style = style; + try { + regex = new Regex(pattern, RegexCompileFlags.OPTIMIZE); + } catch (RegexError error) { + GLib.error("Invalid syntax highlighting rule: %s", error.message); + } + } + + public bool find(string text, int offset, out int start, out int end) { + MatchInfo match_info; + try { + regex.match_full(text, -1, offset, 0, out match_info); + } catch (RegexError error) { + start = -1; + end = -1; + return false; + } + if (!match_info.matches()) { + start = -1; + end = -1; + return false; + } + match_info.fetch_pos(0, out start, out end); + return true; + } +} + +public class LanguageDefinition : Object { + public string id; + public string name; + public string extension; + public string[] aliases; + public HighlightRule[] rules; + + public LanguageDefinition(string id, string name, string extension, string[] aliases, HighlightRule[] rules) { + this.id = id; + this.name = name; + this.extension = extension; + this.aliases = aliases; + this.rules = rules; + } + + public bool matches(string language) { + string normalized = language.down().strip(); + if (normalized == id) return true; + foreach (string alias in aliases) { + if (normalized == alias) return true; + } + return false; + } + + public ArrayList<HighlightSpan> highlight(string text) { + var spans = new ArrayList<HighlightSpan>(); + int offset = 0; + while (offset < text.length) { + HighlightRule? selected_rule = null; + int selected_start = text.length; + int selected_end = text.length; + foreach (HighlightRule rule in rules) { + int start; + int end; + if (rule.find(text, offset, out start, out end) && start < selected_start) { + selected_rule = rule; + selected_start = start; + selected_end = end; + } + } + if (selected_rule == null || selected_end <= selected_start) break; + spans.add(new HighlightSpan(selected_start, selected_end, selected_rule.style)); + offset = selected_end; + } + return spans; + } +} + +private static LanguageDefinition[]? definitions; + +public static LanguageDefinition? find(string language) { + if (definitions == null) definitions = create_definitions(); + foreach (LanguageDefinition definition in definitions) { + if (definition.matches(language)) return definition; + } + return null; +} + +private static LanguageDefinition[] create_definitions() { + return { create_c_definition() }; +} + +private static LanguageDefinition create_c_definition() { + HighlightRule[] rules = { + new HighlightRule(TokenStyle.COMMENT, "//[^\\r\\n]*|/\\*[\\s\\S]*?\\*/"), + new HighlightRule(TokenStyle.STRING, "\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'"), + new HighlightRule(TokenStyle.PREPROCESSOR, "(?m)^[\\t ]*#[\\t ]*[A-Za-z_][A-Za-z0-9_]*"), + new HighlightRule(TokenStyle.KEYWORD, "\\b(?:alignas|alignof|asm|auto|break|case|const|constexpr|continue|default|do|else|enum|extern|for|goto|if|inline|register|restrict|return|sizeof|static|static_assert|struct|switch|thread_local|typedef|typeof|typeof_unqual|union|volatile|while|_Alignas|_Alignof|_Atomic|_Generic|_Noreturn|_Static_assert|_Thread_local)\\b"), + new HighlightRule(TokenStyle.TYPE, "\\b(?:bool|char|char8_t|double|float|int|long|short|signed|unsigned|void|_Bool|_Complex|_Decimal32|_Decimal64|_Decimal128|_Imaginary|int8_t|int16_t|int32_t|int64_t|uint8_t|uint16_t|uint32_t|uint64_t|intptr_t|uintptr_t|ptrdiff_t|size_t|ssize_t|wchar_t)\\b"), + new HighlightRule(TokenStyle.CONSTANT, "\\b(?:false|NULL|nullptr|true)\\b"), + new HighlightRule(TokenStyle.NUMBER, "\\b(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|(?:[0-9]+\\.[0-9]*|[0-9]+)(?:[eEpP][+-]?[0-9]+)?)[fFlLuU]*\\b"), + new HighlightRule(TokenStyle.FUNCTION, "\\b[A-Za-z_][A-Za-z0-9_]*(?=[\\t ]*\\()") + }; + return new LanguageDefinition("c", "C", "c", { "h", "c89", "c90", "c99", "c11", "c17", "c18", "c23" }, rules); +} + +} diff --git a/main/src/ui/conversation_selector/conversation_selector.vala b/main/src/ui/conversation_selector/conversation_selector.vala index 70e192b7..25ea1b1e 100644 --- a/main/src/ui/conversation_selector/conversation_selector.vala +++ b/main/src/ui/conversation_selector/conversation_selector.vala @@ -14,6 +14,7 @@ public class ConversationSelector : Widget { ListBox list_box = new ListBox() { hexpand=true }; private StreamInteractor stream_interactor; + private string search_text = ""; private HashMap<Conversation, ConversationSelectorRow> rows = new HashMap<Conversation, ConversationSelectorRow>(Conversation.hash_func, Conversation.equals_func); public ConversationSelector init(StreamInteractor stream_interactor) { @@ -38,6 +39,7 @@ public class ConversationSelector : Widget { } construct { + list_box.set_filter_func(filter); list_box.set_sort_func(sort); realize.connect(() => { @@ -53,6 +55,16 @@ public class ConversationSelector : Widget { check_widget_leak(this); } + public void set_search_text(string text) { + search_text = text.strip().casefold(); + list_box.invalidate_filter(); + } + + private bool filter(ListBoxRow list_box_row) { + ConversationSelectorRow? row = list_box_row as ConversationSelectorRow; + return row != null && (search_text == "" || row.matches_search(search_text)); + } + public void row_activated(ListBoxRow r) { ConversationSelectorRow? row = r as ConversationSelectorRow; if (row != null) { diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index 0e565c84..95384a5e 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -13,6 +13,7 @@ namespace Dino.Ui { public class ConversationSelectorRow : ListBoxRow { [GtkChild] protected unowned AvatarPicture picture; + [GtkChild] protected unowned DrawingArea account_color_indicator; [GtkChild] protected unowned Label name_label; [GtkChild] protected unowned Label time_label; [GtkChild] protected unowned Label nick_label; @@ -42,6 +43,7 @@ public class ConversationSelectorRow : ListBoxRow { var display_name_model = stream_interactor.get_module(ContactModels.IDENTITY).get_display_name_model(conversation); display_name_model.bind_property("display-name", name_label, "label", BindingFlags.SYNC_CREATE); + name_label.notify["label"].connect(changed); // Set tooltip switch (conversation.type_) { @@ -63,6 +65,7 @@ public class ConversationSelectorRow : ListBoxRow { last_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); picture.model = new ViewModel.CompatAvatarPictureModel(stream_interactor).set_conversation(conversation); + account_color_indicator.set_draw_func(draw_account_color); conversation.notify["read-up-to-item"].connect(() => update_read()); conversation.notify["pinned"].connect(() => { update_pinned_icon(); }); @@ -73,9 +76,31 @@ public class ConversationSelectorRow : ListBoxRow { connect_signals_weak(this); } + private static void draw_account_color(DrawingArea area, Cairo.Context context, int width, int height) { + ConversationSelectorRow? row = area.get_ancestor(typeof(ConversationSelectorRow)) as ConversationSelectorRow; + if (row == null) return; + + Gdk.RGBA color = Gdk.RGBA(); + if (!color.parse(Dino.Application.get_default().settings.get_account_color(row.conversation.account))) return; + + Gdk.cairo_set_source_rgba(context, color); + context.rectangle(0, 0, width, height); + context.fill(); + } + private static void connect_signals_weak(ConversationSelectorRow local_self) { var self_weak = WeakRef(local_self); + var settings = Dino.Application.get_default().settings; + ulong account_color_changed_handler_id = 0; + account_color_changed_handler_id = settings.account_color_changed.connect((account, color) => { + ConversationSelectorRow? self = self_weak.get() as ConversationSelectorRow?; + if (self == null) return; + if (self.conversation.account.equals(account)) { + self.account_color_indicator.queue_draw(); + } + }); + ulong room_info_updated_handler_id = 0; if (local_self.conversation.type_ == Conversation.Type.GROUPCHAT) { room_info_updated_handler_id = local_self.stream_interactor.get_module(MucManager.IDENTITY).room_info_updated.connect((account, jid) => { @@ -116,6 +141,7 @@ public class ConversationSelectorRow : ListBoxRow { local_self.weak_ref((self_obj) => { ConversationSelectorRow self = (ConversationSelectorRow) self_obj; + settings.disconnect(account_color_changed_handler_id); if (room_info_updated_handler_id != 0) { self.stream_interactor.get_module(MucManager.IDENTITY).disconnect(room_info_updated_handler_id); } @@ -154,6 +180,11 @@ public class ConversationSelectorRow : ListBoxRow { name_label.label = Util.get_conversation_display_name(stream_interactor, conversation); } + public bool matches_search(string search_text) { + return name_label.label.casefold().contains(search_text) || + conversation.counterpart.bare_jid.to_string().casefold().contains(search_text); + } + private void update_pinned_icon() { pinned_image.visible = conversation.pinned != 0; } diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index 9626a39d..bff8a70b 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -53,6 +53,16 @@ public class ConversationViewController : Object { key_controller3.key_pressed.connect(forward_key_press_to_chat_input); main_window.conversation_headerbar.add_controller(key_controller3); + main_window.conversation_title_button.clicked.connect(() => { + if (conversation == null) return; + + var variant = new Variant.tuple(new Variant[] { + new Variant.int32(conversation.id), + new Variant.string("about") + }); + app.activate_action("open-conversation-details", variant); + }); + // goto-end floating button var vadjustment = view.conversation_frame.scrolled.vadjustment; vadjustment.notify["value"].connect(() => { @@ -113,6 +123,7 @@ public class ConversationViewController : Object { } this.conversation = conversation; + main_window.conversation_title_button.sensitive = true; // Set list model onto list view // Dino.Application app = GLib.Application.get_default() as Dino.Application; @@ -143,6 +154,7 @@ public class ConversationViewController : Object { } public void unset_conversation() { + main_window.conversation_title_button.sensitive = false; main_window.conversation_window_title.title = ""; main_window.conversation_window_title.subtitle = ""; } diff --git a/main/src/ui/launcher_badge.vala b/main/src/ui/launcher_badge.vala new file mode 100644 index 00000000..aa07fbaa --- /dev/null +++ b/main/src/ui/launcher_badge.vala @@ -0,0 +1,118 @@ +using Gee; +using GLib; + +using Dino.Entities; + +namespace Dino.Ui { + +[DBus (name = "com.canonical.Unity.LauncherEntry")] +public class LauncherBadge : Object { + private const string APP_URI = "application://im.dino.Dino.desktop"; + private const string BUS_NAME = "com.canonical.Unity"; + private const string OBJECT_PATH = "/im/dino/Dino/LauncherEntry"; + + private StreamInteractor stream_interactor; + private DBusConnection connection; + private HashMap<Conversation, ulong> read_handler_ids = new HashMap<Conversation, ulong>(Conversation.hash_func, Conversation.equals_func); + private int64 unread_count = -1; + private uint object_registration_id; + private uint watcher_id; + private ulong conversation_activated_handler_id; + private ulong conversation_deactivated_handler_id; + private ulong new_item_handler_id; + private ulong setting_handler_id; + + public signal void update(string app_uri, HashTable<string, Variant> properties); + + public LauncherBadge(StreamInteractor stream_interactor) throws IOError { + this.stream_interactor = stream_interactor; + connection = Bus.get_sync(BusType.SESSION); + object_registration_id = connection.register_object(OBJECT_PATH, this); + + ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY); + conversation_activated_handler_id = conversation_manager.conversation_activated.connect(add_conversation); + conversation_deactivated_handler_id = conversation_manager.conversation_deactivated.connect(remove_conversation); + new_item_handler_id = stream_interactor.get_module(ContentItemStore.IDENTITY).new_item.connect(() => update_count()); + setting_handler_id = Dino.Application.get_default().settings.notify["notifications-badge"].connect(() => update_count(true)); + + foreach (Conversation conversation in conversation_manager.get_active_conversations()) { + connect_conversation(conversation); + } + + watcher_id = Bus.watch_name_on_connection( + connection, + BUS_NAME, + BusNameWatcherFlags.NONE, + () => update_count(true)); + update_count(true); + } + + public void query(out string app_uri, out HashTable<string, Variant> properties) throws Error { + app_uri = APP_URI; + properties = get_properties(); + } + + internal void shutdown() { + unread_count = 0; + update(APP_URI, get_properties()); + + ConversationManager conversation_manager = stream_interactor.get_module(ConversationManager.IDENTITY); + conversation_manager.disconnect(conversation_activated_handler_id); + conversation_manager.disconnect(conversation_deactivated_handler_id); + stream_interactor.get_module(ContentItemStore.IDENTITY).disconnect(new_item_handler_id); + Dino.Application.get_default().settings.disconnect(setting_handler_id); + + foreach (Conversation conversation in read_handler_ids.keys) { + conversation.disconnect(read_handler_ids[conversation]); + } + read_handler_ids.clear(); + + if (watcher_id != 0) { + Bus.unwatch_name(watcher_id); + watcher_id = 0; + } + if (object_registration_id != 0) { + connection.unregister_object(object_registration_id); + object_registration_id = 0; + } + } + + private void add_conversation(Conversation conversation) { + connect_conversation(conversation); + update_count(); + } + + private void remove_conversation(Conversation conversation) { + if (read_handler_ids.has_key(conversation)) { + conversation.disconnect(read_handler_ids[conversation]); + read_handler_ids.unset(conversation); + } + update_count(); + } + + private void connect_conversation(Conversation conversation) { + if (read_handler_ids.has_key(conversation)) return; + read_handler_ids[conversation] = conversation.notify["read-up-to-item"].connect(() => update_count()); + } + + private void update_count(bool force = false) { + int64 count = 0; + ChatInteraction chat_interaction = stream_interactor.get_module(ChatInteraction.IDENTITY); + foreach (Conversation conversation in stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations()) { + count += chat_interaction.get_num_unread(conversation); + } + + if (!force && count == unread_count) return; + unread_count = count; + update(APP_URI, get_properties()); + } + + private HashTable<string, Variant> get_properties() { + HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal); + properties["count"] = new Variant.int64(unread_count); + properties["count-visible"] = new Variant.boolean(Dino.Application.get_default().settings.notifications_badge && unread_count > 0); + return properties; + } +} + +} diff --git a/main/src/ui/main_window.vala b/main/src/ui/main_window.vala index da6960ea..fc18d2bd 100644 --- a/main/src/ui/main_window.vala +++ b/main/src/ui/main_window.vala @@ -7,17 +7,19 @@ using Dino.Entities; namespace Dino.Ui { [GtkTemplate (ui = "/im/dino/Dino/main_window.ui")] -public class MainWindow : Adw.ApplicationWindow { +public class MainWindow : Gtk.ApplicationWindow { public signal void conversation_selected(Conversation conversation); [GtkChild] public unowned Stack stack; [GtkChild] public unowned Adw.NavigationSplitView navigation_split_view; - [GtkChild] public unowned MenuButton add_button; + [GtkChild] public unowned SearchEntry conversation_search_entry; [GtkChild] public unowned MenuButton menu_button; + [GtkChild] private unowned Box conversation_list_toolbar; [GtkChild] public unowned Adw.HeaderBar conversation_headerbar; + [GtkChild] public unowned Button conversation_title_button; [GtkChild] public unowned Adw.WindowTitle conversation_window_title; [GtkChild] public unowned ConversationView conversation_view; @@ -56,15 +58,25 @@ public class MainWindow : Adw.ApplicationWindow { this.add_css_class("devel"); } + var header_size_group = new SizeGroup(SizeGroupMode.VERTICAL); + header_size_group.add_widget(conversation_list_toolbar); + header_size_group.add_widget(conversation_headerbar); + ((Widget)this).realize.connect(restore_window_size); conversation_selector.init(stream_interactor); conversation_selector.conversation_selected.connect_after(() => { navigation_split_view.show_content = true; }); + conversation_search_entry.search_changed.connect(() => { + conversation_selector.set_search_text(conversation_search_entry.text); + }); + conversation_search_entry.stop_search.connect(() => { + conversation_search_entry.text = ""; + }); global_search = new GlobalSearch(stream_interactor); search_frame.set_child(global_search.get_widget()); - create_add_menu(add_button, menu_button); + create_menu(menu_button); stack.add_named(welcome_placeholder, "welcome_placeholder"); stack.add_named(accounts_placeholder, "accounts_placeholder"); @@ -137,13 +149,8 @@ public class MainWindow : Adw.ApplicationWindow { } } - private static void create_add_menu(MenuButton add_button, MenuButton menu_button) { - add_button.tooltip_text = Util.string_if_tooltips_active(_("Start Conversation")); - - Builder add_builder = new Builder.from_resource("/im/dino/Dino/menu_add.ui"); - MenuModel add_menu_model = add_builder.get_object("menu_add") as MenuModel; - add_button.set_menu_model(add_menu_model); - + private static void create_menu(MenuButton menu_button) { + menu_button.tooltip_text = Util.string_if_tooltips_active(_("Main Menu")); Builder menu_builder = new Builder.from_resource("/im/dino/Dino/menu_app.ui"); MenuModel menu_menu_model = menu_builder.get_object("menu_app") as MenuModel; menu_button.set_menu_model(menu_menu_model); diff --git a/main/src/ui/notifier_freedesktop.vala b/main/src/ui/notifier_freedesktop.vala index 99428315..3c8bee1c 100644 --- a/main/src/ui/notifier_freedesktop.vala +++ b/main/src/ui/notifier_freedesktop.vala @@ -11,10 +11,12 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { private DBusNotifications dbus_notifications; private bool supports_body_markup = false; private bool supports_body_hyperlinks = false; + private bool supports_inline_reply = false; private HashMap<Conversation, uint32> content_notifications = new HashMap<Conversation, uint32>(Conversation.hash_func, Conversation.equals_func); private HashMap<Conversation, Gee.List<uint32>> conversation_notifications = new HashMap<Conversation, Gee.List<uint32>>(Conversation.hash_func, Conversation.equals_func); private HashMap<uint32, HashMap<string, ListenerFuncWrapper>> action_listeners = new HashMap<uint32, HashMap<string, ListenerFuncWrapper>>(); + private HashMap<uint32, NotificationReplyTarget> reply_targets = new HashMap<uint32, NotificationReplyTarget>(); private HashMap<Call, uint32> call_notifications = new HashMap<Call, uint32>(Call.hash_func, Call.equals_func); public FreeDesktopNotifier(StreamInteractor stream_interactor, DBusNotifications dbus_notifications) { @@ -35,6 +37,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { case "body-hyperlinks": supports_body_hyperlinks = true; break; + case "inline-reply": + supports_inline_reply = true; + break; } } @@ -46,6 +51,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { dbus_notifications.notification_closed.connect((id) => { action_listeners.unset(id); + reply_targets.unset(id); + }); + + dbus_notifications.notification_replied.connect((id, text) => { + if (!reply_targets.has_key(id)) return; + + NotificationReplyTarget target = reply_targets[id]; + if (text.strip() != "") { + Dino.send_message(target.conversation, text, target.content_item_id, null, new ArrayList<Xep.MessageMarkup.Span>()); + } + + reply_targets.unset(id); + if (content_notifications.has_key(target.conversation) && content_notifications[target.conversation] == id) { + content_notifications.unset(target.conversation); + } + dbus_notifications.close_notification.begin(id); }); } @@ -62,7 +83,9 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } else { body = message.body; } - yield notify_content_item(conversation, conversation_display_name, participant_display_name, body); + ContentItem? reply_to_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_content_item_for_message(conversation, message); + int reply_to_id = reply_to_item != null ? reply_to_item.id : 0; + yield notify_content_item(conversation, conversation_display_name, participant_display_name, body, reply_to_id); } public async void notify_file(FileTransfer file_transfer, Conversation conversation, bool is_image, string conversation_display_name, string? participant_display_name) { @@ -77,10 +100,10 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { text = "<i>" + text + "</i>"; } - yield notify_content_item(conversation, conversation_display_name, participant_display_name, text); + yield notify_content_item(conversation, conversation_display_name, participant_display_name, text, 0); } - private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_) { + private async void notify_content_item(Conversation conversation, string conversation_display_name, string? participant_display_name, string body_, int reply_to_id) { string body = body_; if (participant_display_name != null) { if (supports_body_markup) { @@ -96,10 +119,22 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { hash_table["desktop-entry"] = new Variant.string(Dino.Application.get_default().get_application_id()); hash_table["category"] = new Variant.string("im.received"); string[] actions = new string[] {"default", "Open conversation"}; + if (reply_to_id != 0 && supports_inline_reply) { + actions += "inline-reply"; + actions += _("Reply"); + hash_table["x-kde-reply-placeholder-text"] = new Variant.string(_("Reply")); + hash_table["x-kde-reply-submit-button-text"] = new Variant.string(_("Send")); + } try { uint32 notification_id = yield dbus_notifications.notify("Dino", replace_id, "", conversation_display_name, body, actions, hash_table, -1); content_notifications[conversation] = notification_id; + if (reply_to_id != 0 && supports_inline_reply) { + reply_targets[notification_id] = new NotificationReplyTarget(conversation, reply_to_id); + } else { + reply_targets.unset(notification_id); + } + add_action_listener(notification_id, "default", () => { GLib.Application.get_default().activate_action("open-conversation", new Variant.int32(conversation.id)); }); @@ -284,12 +319,15 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } catch (Error e) { } } content_notifications.clear(); + reply_targets.clear(); } public async void retract_conversation_notifications(Conversation conversation) { try { if (content_notifications.has_key(conversation)) { - dbus_notifications.close_notification.begin(content_notifications[conversation]); + uint32 notification_id = content_notifications[conversation]; + dbus_notifications.close_notification.begin(notification_id); + reply_targets.unset(notification_id); content_notifications.unset(conversation); } @@ -323,6 +361,17 @@ public class Dino.Ui.FreeDesktopNotifier : NotificationProvider, Object { } delegate void ListenerFunc(); + + class NotificationReplyTarget { + public Conversation conversation; + public int content_item_id; + + public NotificationReplyTarget(Conversation conversation, int content_item_id) { + this.conversation = conversation; + this.content_item_id = content_item_id; + } + } + class ListenerFuncWrapper { public ListenerFunc func; diff --git a/main/src/ui/status_notifier_item.vala b/main/src/ui/status_notifier_item.vala new file mode 100644 index 00000000..70db17d5 --- /dev/null +++ b/main/src/ui/status_notifier_item.vala @@ -0,0 +1,212 @@ +using GLib; + +namespace Dino.Ui { + +public struct StatusNotifierIconPixmap { + public int width; + public int height; + public uint8[] data; +} + +public struct StatusNotifierToolTip { + public string icon_name; + public StatusNotifierIconPixmap[] icon_pixmap; + public string title; + public string description; +} + +[DBus (name = "org.kde.StatusNotifierItem")] +public class StatusNotifierItem : Object { + private const string BUS_NAME_TEMPLATE = "org.kde.StatusNotifierItem-%d-1"; + private const string ICON_NAME = "im.dino.Dino"; + private const string ITEM_OBJECT_PATH = "/StatusNotifierItem"; + private const string MENU_OBJECT_PATH = "/MenuBar"; + private const string WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher"; + private const string WATCHER_INTERFACE = "org.kde.StatusNotifierWatcher"; + private const string WATCHER_OBJECT_PATH = "/StatusNotifierWatcher"; + + private weak Application application; + private DBusConnection connection; + private string bus_name; + private bool name_acquired; + private bool registration_in_progress; + private bool shutting_down; + private bool watcher_available; + private bool registered_with_watcher; + private string? activation_token; + private string? watcher_owner; + private Cancellable registration_cancellable = new Cancellable(); + private StatusNotifierMenu status_notifier_menu; + private uint bus_name_owner_id; + private uint menu_registration_id; + private uint object_registration_id; + private uint watcher_id; + + public string category { owned get { return "Communications"; } } + public string id { owned get { return "Dino"; } } + public string title { owned get { return "Dino"; } } + public string status { owned get { return "Active"; } } + public int window_id { get { return 0; } } + public string icon_theme_path { owned get { return ""; } } + public ObjectPath menu { owned get { return new ObjectPath(MENU_OBJECT_PATH); } } + public bool item_is_menu { get { return false; } } + public string icon_name { owned get { return ICON_NAME; } } + public StatusNotifierIconPixmap[] icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string overlay_icon_name { owned get { return ""; } } + public StatusNotifierIconPixmap[] overlay_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string attention_icon_name { owned get { return ""; } } + public StatusNotifierIconPixmap[] attention_icon_pixmap { owned get { return new StatusNotifierIconPixmap[0]; } } + public string attention_movie_name { owned get { return ""; } } + public StatusNotifierToolTip tool_tip { + owned get { + StatusNotifierToolTip tool_tip = StatusNotifierToolTip(); + tool_tip.icon_name = ICON_NAME; + tool_tip.icon_pixmap = new StatusNotifierIconPixmap[0]; + tool_tip.title = "Dino"; + tool_tip.description = _("Open Dino"); + return tool_tip; + } + } + + public signal void new_title(); + public signal void new_icon(); + public signal void new_attention_icon(); + public signal void new_overlay_icon(); + public signal void new_menu(); + public signal void new_tool_tip(); + public signal void new_status(string status); + + public StatusNotifierItem(Application application) throws IOError { + this.application = application; + this.connection = Bus.get_sync(BusType.SESSION); + this.bus_name = BUS_NAME_TEMPLATE.printf((int) Posix.getpid()); + + status_notifier_menu = new StatusNotifierMenu(application); + menu_registration_id = connection.register_object(MENU_OBJECT_PATH, status_notifier_menu); + object_registration_id = connection.register_object(ITEM_OBJECT_PATH, this); + bus_name_owner_id = Bus.own_name_on_connection( + connection, + bus_name, + BusNameOwnerFlags.NONE, + () => { + name_acquired = true; + register_with_watcher.begin(); + }, + () => { + name_acquired = false; + registered_with_watcher = false; + }); + watcher_id = Bus.watch_name_on_connection( + connection, + WATCHER_BUS_NAME, + BusNameWatcherFlags.NONE, + (watcher_connection, watcher_name, name_owner) => { + watcher_owner = name_owner; + watcher_available = true; + registered_with_watcher = false; + register_with_watcher.begin(); + }, + () => { + watcher_owner = null; + watcher_available = false; + registered_with_watcher = false; + }); + } + + internal void shutdown() { + shutting_down = true; + name_acquired = false; + watcher_available = false; + registration_cancellable.cancel(); + + if (watcher_id != 0) { + Bus.unwatch_name(watcher_id); + watcher_id = 0; + } + if (bus_name_owner_id != 0) { + Bus.unown_name(bus_name_owner_id); + bus_name_owner_id = 0; + } + if (object_registration_id != 0) { + connection.unregister_object(object_registration_id); + object_registration_id = 0; + } + if (menu_registration_id != 0) { + connection.unregister_object(menu_registration_id); + menu_registration_id = 0; + } + } + + public void provide_xdg_activation_token(string token) throws Error { + activation_token = token; + } + + public void context_menu(int x, int y) throws Error { + } + + public void activate(int x, int y) throws Error { + if (application.window != null && application.window.is_active) { + application.window.visible = false; + activation_token = null; + return; + } + + show_window(); + } + + public void secondary_activate(int x, int y) throws Error { + show_window(); + } + + public void scroll(int delta, string orientation) throws Error { + } + + private async void register_with_watcher() { + if (shutting_down || !name_acquired || !watcher_available || + registered_with_watcher || registration_in_progress) return; + + registration_in_progress = true; + string? registering_watcher = watcher_owner; + + try { + yield connection.call( + WATCHER_BUS_NAME, + WATCHER_OBJECT_PATH, + WATCHER_INTERFACE, + "RegisterStatusNotifierItem", + new Variant("(s)", bus_name), + null, + DBusCallFlags.NONE, + 5000, + registration_cancellable); + if (!shutting_down && name_acquired && watcher_owner == registering_watcher) { + registered_with_watcher = true; + } + } catch (Error e) { + if (!(e is IOError.CANCELLED)) { + warning("Could not register Dino's tray icon: %s", e.message); + } + } + + registration_in_progress = false; + if (!shutting_down && name_acquired && watcher_available && !registered_with_watcher && + watcher_owner != registering_watcher) { + register_with_watcher.begin(); + } + } + + private void show_window() { + if (application.window == null) { + application.activate(); + return; + } + + if (activation_token != null) { + application.window.set_startup_id(activation_token); + activation_token = null; + } + application.window.present(); + } +} + +} diff --git a/main/src/ui/status_notifier_menu.vala b/main/src/ui/status_notifier_menu.vala new file mode 100644 index 00000000..cc8b1375 --- /dev/null +++ b/main/src/ui/status_notifier_menu.vala @@ -0,0 +1,100 @@ +using GLib; + +namespace Dino.Ui { + +public struct DBusMenuLayout { + public int id; + public HashTable<string, Variant> properties; + public Variant[] children; +} + +[DBus (name = "com.canonical.dbusmenu")] +public class StatusNotifierMenu : Object { + private const int ROOT_ITEM_ID = 0; + private const int QUIT_ITEM_ID = 1; + + private weak Application application; + private bool quit_scheduled; + + public uint version { get { return 3; } } + public string text_direction { owned get { return "ltr"; } } + public string status { owned get { return "normal"; } } + public string[] icon_theme_path { owned get { return new string[0]; } } + + public signal void layout_updated(uint revision, int parent); + + public StatusNotifierMenu(Application application) { + this.application = application; + } + + public void get_layout(int parent_id, int recursion_depth, string[] property_names, + out uint revision, out DBusMenuLayout layout) throws Error { + revision = 1; + layout = create_layout(parent_id, recursion_depth, property_names); + } + + public void @event(int id, string event_id, Variant data, uint timestamp) throws Error { + if (id != QUIT_ITEM_ID || event_id != "clicked" || quit_scheduled) return; + + quit_scheduled = true; + Idle.add(() => { + application.quit(); + return Source.REMOVE; + }); + } + + public bool about_to_show(int id) throws Error { + return false; + } + + private DBusMenuLayout create_layout(int id, int recursion_depth, string[] property_names) { + DBusMenuLayout layout = DBusMenuLayout(); + layout.id = id; + layout.properties = get_item_properties(id, property_names); + layout.children = new Variant[0]; + + if (id == ROOT_ITEM_ID && recursion_depth != 0) { + DBusMenuLayout quit_item = create_layout(QUIT_ITEM_ID, 0, property_names); + layout.children = new Variant[] { layout_to_variant(quit_item) }; + } + return layout; + } + + private HashTable<string, Variant> get_item_properties(int id, string[] property_names) { + HashTable<string, Variant> properties = new HashTable<string, Variant>(str_hash, str_equal); + + if (id == ROOT_ITEM_ID) { + add_property(properties, property_names, "children-display", new Variant.string("submenu")); + } else if (id == QUIT_ITEM_ID) { + add_property(properties, property_names, "label", new Variant.string(_("Quit Dino"))); + } + return properties; + } + + private static void add_property(HashTable<string, Variant> properties, string[] property_names, + string name, Variant value) { + if (property_names.length == 0 || name in property_names) { + properties.insert(name, value); + } + } + + private static Variant layout_to_variant(DBusMenuLayout layout) { + VariantBuilder properties = new VariantBuilder(new VariantType("a{sv}")); + layout.properties.foreach((name, value) => { + properties.add("{sv}", name, value); + }); + + Variant[] children = new Variant[layout.children.length]; + for (int i = 0; i < layout.children.length; i++) { + children[i] = new Variant.variant(layout.children[i]); + } + + return new Variant.tuple(new Variant[] { + new Variant.int32(layout.id), + properties.end(), + new Variant.array(VariantType.VARIANT, children) + }); + } +} + +} diff --git a/main/src/ui/widgets/avatar_picture.vala b/main/src/ui/widgets/avatar_picture.vala index d22f9cfe..b4636f49 100644 --- a/main/src/ui/widgets/avatar_picture.vala +++ b/main/src/ui/widgets/avatar_picture.vala @@ -272,6 +272,13 @@ public class Dino.Ui.CompatAvatarDrawer { public int width_request { get; set; default = 35; } public string font_family { get; set; default = "Sans"; } + public CompatAvatarDrawer() { + Dino.Application? application = Dino.Application.get_default(); + if (application != null && application.settings != null) { + radius_percent = (float) application.settings.avatar_roundness / 100.0f; + } + } + public Cairo.ImageSurface draw_image_surface() { Cairo.ImageSurface surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, width_request, height_request); draw_on_context(new Cairo.Context(surface)); @@ -392,12 +399,17 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget { private ViewModel.AvatarPictureModel? old_model; private ulong model_tiles_items_changed_handler; + private Binding? avatar_roundness_binding; construct { height_request = 35; width_request = 35; set_css_name("picture"); add_css_class("avatar"); + Dino.Application? application = Dino.Application.get_default(); + if (application != null && application.settings != null) { + avatar_roundness_binding = application.settings.bind_property("avatar-roundness", this, "radius-percent", BindingFlags.SYNC_CREATE, avatar_roundness_to_radius_percent); + } notify["radius-percent"].connect(queue_draw); notify["model"].connect(on_model_changed); check_widget_leak(this); @@ -488,11 +500,20 @@ public class Dino.Ui.AvatarPicture : Gtk.Widget { } public override void dispose() { + if (avatar_roundness_binding != null) { + avatar_roundness_binding.unbind(); + avatar_roundness_binding = null; + } model = null; on_model_changed(); base.dispose(); } + private static bool avatar_roundness_to_radius_percent(Binding binding, Value from_value, ref Value to_value) { + to_value.set_float((float) from_value.get_int() / 100.0f); + return true; + } + private class Tile : Gtk.Widget { public ViewModel.AvatarPictureTileModel? model { get; set; } public Gdk.RGBA background_color { get; set; default = Gdk.RGBA(){ red = 1.0f, green = 1.0f, blue = 1.0f, alpha = 0.0f }; } diff --git a/main/src/view_model/preferences_dialog.vala b/main/src/view_model/preferences_dialog.vala index 20e11ae3..6bc8d56f 100644 --- a/main/src/view_model/preferences_dialog.vala +++ b/main/src/view_model/preferences_dialog.vala @@ -14,6 +14,7 @@ public class Dino.Ui.ViewModel.PreferencesDialog : Object { public Database db; public GeneralPreferencesPage general_page { get; set; default=new GeneralPreferencesPage(); } + public TweaksPreferencesPage tweaks_page { get; set; default=new TweaksPreferencesPage(); } public void populate(Database db, StreamInteractor stream_interactor) { this.db = db; @@ -36,6 +37,7 @@ public class Dino.Ui.ViewModel.PreferencesDialog : Object { stream_interactor.account_removed.connect(update_data); bind_general_page(); + bind_tweaks_page(); update_data(); } @@ -110,9 +112,22 @@ public class Dino.Ui.ViewModel.PreferencesDialog : Object { 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("notifications", general_page, "notifications-messages", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-files", general_page, "notifications-files", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-calls", general_page, "notifications-calls", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-subscription-requests", general_page, "notifications-subscription-requests", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-group-chat-invitations", general_page, "notifications-group-chat-invitations", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-permission-requests", general_page, "notifications-permission-requests", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-connection-errors", general_page, "notifications-connection-errors", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("notifications-badge", general_page, "notifications-badge", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); settings.bind_property("convert-utf8-smileys", general_page, "convert-emojis", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); } + + private void bind_tweaks_page() { + var settings = Dino.Application.get_default().settings; + settings.bind_property("avatar-roundness", tweaks_page, "avatar-roundness", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + settings.bind_property("custom-css", tweaks_page, "custom-css", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL); + } } public class Dino.Ui.ViewModel.ChangePasswordDialog : Object { @@ -127,4 +142,3 @@ public class Dino.Ui.ViewModel.ChangePasswordDialog : Object { return res; } } - diff --git a/main/src/windows/preferences_window/account_preferences_subpage.vala b/main/src/windows/preferences_window/account_preferences_subpage.vala index cc1e7b5e..d52a8c83 100644 --- a/main/src/windows/preferences_window/account_preferences_subpage.vala +++ b/main/src/windows/preferences_window/account_preferences_subpage.vala @@ -12,6 +12,7 @@ public class Dino.Ui.AccountPreferencesSubpage : Adw.NavigationPage { [GtkChild] public unowned AvatarPicture avatar; [GtkChild] public unowned Adw.ActionRow xmpp_address; [GtkChild] public unowned Adw.EntryRow local_alias; + [GtkChild] public unowned Box account_color_box; [GtkChild] public unowned Adw.ActionRow password_change; [GtkChild] public unowned Adw.ActionRow connection_status; [GtkChild] public unowned Button enter_password_button; @@ -28,6 +29,12 @@ public class Dino.Ui.AccountPreferencesSubpage : Adw.NavigationPage { private Binding[] bindings = new Binding[0]; private ulong[] account_notify_ids = new ulong[0]; private ulong alias_entry_changed = 0; + private bool updating_account_color = false; +#if GTK_4_12 + private ColorDialogButton account_color_button; +#else + private ColorButton account_color_button; +#endif construct { title = "Account"; @@ -45,6 +52,23 @@ public class Dino.Ui.AccountPreferencesSubpage : Adw.NavigationPage { remove_account_button.clicked.connect(() => { show_remove_account_dialog(); }); + +#if GTK_4_12 + var color_dialog = new ColorDialog() { + title = _("Choose account color"), + with_alpha = false + }; + account_color_button = new ColorDialogButton((owned) color_dialog) { valign=Align.CENTER }; +#else + account_color_button = new ColorButton() { + title = _("Choose account color"), + use_alpha = false, + valign = Align.CENTER + }; +#endif + account_color_button.notify["rgba"].connect(on_account_color_changed); + account_color_box.append(account_color_button); + password_change.activatable_widget = new Label(""); password_change.activated.connect(() => { var dialog = new ChangePasswordDialog(model.get_change_password_dialog_model()); @@ -121,6 +145,12 @@ public class Dino.Ui.AccountPreferencesSubpage : Adw.NavigationPage { account.alias = local_alias.text; }); + Gdk.RGBA account_color = Gdk.RGBA(); + account_color.parse(Dino.Application.get_default().settings.get_account_color(account)); + updating_account_color = true; + account_color_button.rgba = account_color; + updating_account_color = false; + bindings += account.bind_property("enabled", disable_account_button, "label", BindingFlags.SYNC_CREATE, account_enabled_to_disable_button_label); bindings += account.bind_property("enabled", avatar_menu_box, "visible", BindingFlags.SYNC_CREATE); bindings += account.bind_property("enabled", password_change, "visible", BindingFlags.SYNC_CREATE); @@ -137,6 +167,19 @@ public class Dino.Ui.AccountPreferencesSubpage : Adw.NavigationPage { on_connection_error_updated(); } + private void on_account_color_changed() { + if (updating_account_color) return; + +#if GTK_4_12 + unowned Gdk.RGBA? selected_color = account_color_button.get_rgba(); + if (selected_color != null) { + Dino.Application.get_default().settings.set_account_color(account, Util.rgba_to_hex(selected_color)); + } +#else + Dino.Application.get_default().settings.set_account_color(account, Util.rgba_to_hex(account_color_button.rgba)); +#endif + } + private void show_select_avatar() { FileChooserNative chooser = new FileChooserNative(_("Select avatar"), (Window)this.get_root(), FileChooserAction.OPEN, _("Select"), _("Cancel")); FileFilter filter = new FileFilter(); @@ -261,4 +304,4 @@ public class Dino.Ui.NaturalDirectionBoxLayout : LayoutManager { } } } -}
\ 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 index 0dcf2118..733de889 100644 --- a/main/src/windows/preferences_window/general_preferences_page.vala +++ b/main/src/windows/preferences_window/general_preferences_page.vala @@ -4,7 +4,14 @@ 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 notifications_messages { get; set; } + public bool notifications_files { get; set; } + public bool notifications_calls { get; set; } + public bool notifications_subscription_requests { get; set; } + public bool notifications_group_chat_invitations { get; set; } + public bool notifications_permission_requests { get; set; } + public bool notifications_connection_errors { get; set; } + public bool notifications_badge { get; set; } public bool convert_emojis { get; set; } } @@ -13,7 +20,14 @@ 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 notification_messages_row; + [GtkChild] private unowned Adw.SwitchRow notification_files_row; + [GtkChild] private unowned Adw.SwitchRow notification_calls_row; + [GtkChild] private unowned Adw.SwitchRow notification_subscription_requests_row; + [GtkChild] private unowned Adw.SwitchRow notification_group_chat_invitations_row; + [GtkChild] private unowned Adw.SwitchRow notification_permission_requests_row; + [GtkChild] private unowned Adw.SwitchRow notification_connection_errors_row; + [GtkChild] private unowned Adw.SwitchRow notification_badge_row; [GtkChild] private unowned Adw.SwitchRow emoji_row; public ViewModel.GeneralPreferencesPage model { get; set; default = new ViewModel.GeneralPreferencesPage(); } @@ -32,7 +46,14 @@ public class Dino.Ui.GeneralPreferencesPage : Adw.PreferencesPage { 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("notifications-messages", notification_messages_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-files", notification_files_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-calls", notification_calls_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-subscription-requests", notification_subscription_requests_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-group-chat-invitations", notification_group_chat_invitations_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-permission-requests", notification_permission_requests_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-connection-errors", notification_connection_errors_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("notifications-badge", notification_badge_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), model.bind_property("convert-emojis", emoji_row, "active", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL) }; } else { diff --git a/main/src/windows/preferences_window/preferences_dialog.vala b/main/src/windows/preferences_window/preferences_dialog.vala index 4baced96..a92575f0 100644 --- a/main/src/windows/preferences_window/preferences_dialog.vala +++ b/main/src/windows/preferences_window/preferences_dialog.vala @@ -10,6 +10,7 @@ public class Dino.Ui.PreferencesDialog : Adw.PreferencesDialog { [GtkChild] public unowned Dino.Ui.PreferencesWindowAccounts accounts_page; [GtkChild] public unowned Dino.Ui.PreferencesWindowEncryption encryption_page; [GtkChild] public unowned Dino.Ui.GeneralPreferencesPage general_page; + [GtkChild] public unowned Dino.Ui.TweaksPreferencesPage tweaks_page; [GtkChild] public unowned ViewModel.PreferencesDialog model { get; } @@ -24,4 +25,4 @@ public class Dino.Ui.PreferencesDialog : Adw.PreferencesDialog { this.push_subpage(account_page); }); } -}
\ No newline at end of file +} diff --git a/main/src/windows/preferences_window/tweaks_preferences_page.vala b/main/src/windows/preferences_window/tweaks_preferences_page.vala new file mode 100644 index 00000000..906ca1ae --- /dev/null +++ b/main/src/windows/preferences_window/tweaks_preferences_page.vala @@ -0,0 +1,33 @@ +using Gtk; + +public class Dino.Ui.ViewModel.TweaksPreferencesPage : Object { + public int avatar_roundness { get; set; } + public string custom_css { get; set; default = ""; } +} + +[GtkTemplate (ui = "/im/dino/Dino/preferences_window/tweaks_preferences_page.ui")] +public class Dino.Ui.TweaksPreferencesPage : Adw.PreferencesPage { + [GtkChild] private unowned Adw.SpinRow avatar_roundness_row; + [GtkChild] private unowned Gtk.TextBuffer custom_css_buffer; + + public ViewModel.TweaksPreferencesPage model { get; set; default = new ViewModel.TweaksPreferencesPage(); } + 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("avatar-roundness", avatar_roundness_row, "value", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL), + model.bind_property("custom-css", custom_css_buffer, "text", BindingFlags.SYNC_CREATE | BindingFlags.BIDIRECTIONAL) + }; + } else { + model_bindings = new Binding[0]; + } + } +} |
