diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 7a05b8ab..1e16cdff 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -37,6 +37,7 @@ SOURCES src/service/entity_info.vala src/service/file_manager.vala src/service/jingle_file_transfers.vala + src/service/message_correction.vala src/service/message_processor.vala src/service/message_storage.vala src/service/module_manager.vala diff --git a/libdino/src/application.vala b/libdino/src/application.vala index ac9a4e4b..1d32fcd1 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -45,6 +45,7 @@ public interface Application : GLib.Application { SearchProcessor.start(stream_interactor, db); Register.start(stream_interactor, db); EntityInfo.start(stream_interactor, db); + MessageCorrection.start(stream_interactor, db); create_actions(); diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index e87d0e3a..bce3bec4 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -57,6 +57,7 @@ public class Message : Object { marked_ = value; } } + public string? edit_to = null; private Database? db; @@ -94,6 +95,8 @@ public class Message : Object { string? real_jid_str = row[db.real_jid.real_jid]; if (real_jid_str != null) real_jid = new Jid(real_jid_str); + edit_to = row[db.message_correction.to_stanza_id]; + notify.connect(on_update); } diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index 6b85a70c..1b2033d9 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -93,7 +93,6 @@ public abstract interface NotificationPopulator : Object { public abstract class MetaConversationItem : Object { public virtual string populator_id { get; set; } public virtual Jid? jid { get; set; default=null; } - public virtual bool dim { get; set; default=false; } public virtual DateTime sort_time { get; set; default = new DateTime.now_utc(); } public virtual long seccondary_sort_indicator { get; set; } public virtual long tertiary_sort_indicator { get; set; } @@ -101,11 +100,19 @@ public abstract class MetaConversationItem : Object { public virtual Encryption encryption { get; set; default = Encryption.NONE; } public virtual Entities.Message.Marked mark { get; set; default = Entities.Message.Marked.NONE; } - public abstract bool can_merge { get; set; } - public abstract bool requires_avatar { get; set; } - public abstract bool requires_header { get; set; } + public bool can_merge { get; set; default=false; } + public bool requires_avatar { get; set; default=false; } + public bool requires_header { get; set; default=false; } + public bool in_edit_mode { get; set; default=false; } public abstract Object? get_widget(WidgetType type); + public abstract Gee.List? get_item_actions(WidgetType type); +} + +public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); +public class MessageAction : Object { + public string icon_name; + public MessageActionEvoked callback; } public abstract class MetaConversationNotification : Object { diff --git a/libdino/src/service/content_item_store.vala b/libdino/src/service/content_item_store.vala index 1e2ee85a..1ea0275e 100644 --- a/libdino/src/service/content_item_store.vala +++ b/libdino/src/service/content_item_store.vala @@ -45,9 +45,13 @@ public class ContentItemStore : StreamInteractionModule, Object { foreach (var row in select) { int provider = row[db.content_item.content_type]; int foreign_id = row[db.content_item.foreign_id]; + DateTime time = new DateTime.from_unix_utc(row[db.content_item.time]); + DateTime local_time = new DateTime.from_unix_utc(row[db.content_item.local_time]); switch (provider) { case 1: - RowOption row_option = db.message.select().with(db.message.id, "=", foreign_id).row(); + RowOption row_option = db.message.select().with(db.message.id, "=", foreign_id) + .outer_join_with(db.message_correction, db.message_correction.message_id, db.message.id) + .row(); if (row_option.is_present()) { Message? message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(foreign_id, conversation); if (message == null) { @@ -58,7 +62,10 @@ public class ContentItemStore : StreamInteractionModule, Object { } } if (message != null) { - items.add(new MessageItem(message, conversation, row[db.content_item.id])); + var message_item = new MessageItem(message, conversation, row[db.content_item.id]); + message_item.display_time = time; + message_item.sort_time = local_time; + items.add(message_item); } } break; @@ -259,6 +266,7 @@ public class MessageItem : ContentItem { public MessageItem(Message message, Conversation conversation, int id) { base(id, TYPE, message.from, message.local_time, message.time, message.encryption, message.marked); + this.message = message; this.conversation = conversation; diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index ebf05637..4dfcb5b4 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 13; + private const int VERSION = 14; public class AccountTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -97,6 +97,18 @@ public class Database : Qlite.Database { } } + public class MessageCorrectionTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column message_id = new Column.Integer("message_id") { unique=true }; + public Column to_stanza_id = new Column.Text("to_stanza_id"); + + internal MessageCorrectionTable(Database db) { + base(db, "message_correction"); + init({id, message_id, to_stanza_id}); + index("message_correction_to_stanza_id_idx", {to_stanza_id}); + } + } + public class RealJidTable : Table { public Column message_id = new Column.Integer("message_id") { primary_key = true }; public Column real_jid = new Column.Text("real_jid"); @@ -247,6 +259,7 @@ public class Database : Qlite.Database { public EntityTable entity { get; private set; } public ContentItemTable content_item { get; private set; } public MessageTable message { get; private set; } + public MessageCorrectionTable message_correction { get; private set; } public RealJidTable real_jid { get; private set; } public FileTransferTable file_transfer { get; private set; } public ConversationTable conversation { get; private set; } @@ -268,6 +281,7 @@ public class Database : Qlite.Database { entity = new EntityTable(this); content_item = new ContentItemTable(this); message = new MessageTable(this); + message_correction = new MessageCorrectionTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); conversation = new ConversationTable(this); @@ -277,7 +291,7 @@ public class Database : Qlite.Database { roster = new RosterTable(this); mam_catchup = new MamCatchupTable(this); settings = new SettingsTable(this); - init({ account, jid, entity, content_item, message, real_jid, file_transfer, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, settings }); + init({ account, jid, entity, content_item, message, message_correction, real_jid, file_transfer, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, settings }); try { exec("PRAGMA synchronous=0"); } catch (Error e) { } @@ -401,14 +415,14 @@ public class Database : Qlite.Database { if (before != null) { if (id > 0) { - select.where(@"local_time < ? OR (local_time = ? AND id < ?)", { before.to_unix().to_string(), before.to_unix().to_string(), id.to_string() }); + select.where(@"local_time < ? OR (local_time = ? AND message.id < ?)", { before.to_unix().to_string(), before.to_unix().to_string(), id.to_string() }); } else { select.with(message.id, "<", id); } } if (after != null) { if (id > 0) { - select.where(@"local_time > ? OR (local_time = ? AND id > ?)", { after.to_unix().to_string(), after.to_unix().to_string(), id.to_string() }); + select.where(@"local_time > ? OR (local_time = ? AND message.id > ?)", { after.to_unix().to_string(), after.to_unix().to_string(), id.to_string() }); } else { select.with(message.local_time, ">", (long) after.to_unix()); } @@ -430,6 +444,7 @@ public class Database : Qlite.Database { } select.outer_join_with(real_jid, real_jid.message_id, message.id); + select.outer_join_with(message_correction, message_correction.message_id, message.id); LinkedList ret = new LinkedList(); foreach (Row row in select) { diff --git a/libdino/src/service/message_correction.vala b/libdino/src/service/message_correction.vala new file mode 100644 index 00000000..320c0b7e --- /dev/null +++ b/libdino/src/service/message_correction.vala @@ -0,0 +1,175 @@ +using Gee; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; +using Qlite; + +namespace Dino { + + +public class MessageCorrection : StreamInteractionModule, MessageListener { + public static ModuleIdentity IDENTITY = new ModuleIdentity("message_correction"); + public string id { get { return IDENTITY.id; } } + + public signal void received_correction(ContentItem content_item); + + private StreamInteractor stream_interactor; + private Database db; + private HashMap> last_messages = new HashMap>(Conversation.hash_func, Conversation.equals_func); + + private HashMap outstanding_correction_nodes = new HashMap(); + + public static void start(StreamInteractor stream_interactor, Database db) { + MessageCorrection m = new MessageCorrection(stream_interactor, db); + stream_interactor.add_module(m); + } + + public MessageCorrection(StreamInteractor stream_interactor, Database db) { + this.stream_interactor = stream_interactor; + this.db = db; + stream_interactor.account_added.connect(on_account_added); + stream_interactor.get_module(MessageProcessor.IDENTITY).received_pipeline.connect(this); + stream_interactor.get_module(MessageProcessor.IDENTITY).build_message_stanza.connect(check_add_correction_node); + stream_interactor.get_module(PresenceManager.IDENTITY).received_offline_presence.connect((jid, account) => { + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(jid.bare_jid, account, Conversation.Type.GROUPCHAT); + if (conversation != null) { + if (last_messages.has_key(conversation)) last_messages[conversation].unset(jid); + } + }); + } + + public void send_correction(Conversation conversation, Message old_message, string correction_text) { + string stanza_id = old_message.edit_to ?? old_message.stanza_id; + + Message out_message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(correction_text, conversation); + out_message.edit_to = stanza_id; + outstanding_correction_nodes[out_message.stanza_id] = stanza_id; + stream_interactor.get_module(MessageStorage.IDENTITY).add_message(out_message, conversation); + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(out_message, conversation); + + db.message_correction.insert() + .value(db.message_correction.message_id, out_message.id) + .value(db.message_correction.to_stanza_id, stanza_id) + .perform(); + + db.content_item.update() + .with(db.content_item.foreign_id, "=", old_message.id) + .with(db.content_item.content_type, "=", 1) + .set(db.content_item.foreign_id, out_message.id) + .perform(); + + on_received_correction(conversation, out_message.id); + } + + public bool is_own_correction_allowed(Conversation conversation, Message message) { + string stanza_id = message.edit_to ?? message.stanza_id; + + Jid own_jid = conversation.account.full_jid; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + own_jid = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account); + } + return last_messages.has_key(conversation) && + last_messages[conversation].has_key(own_jid) && + last_messages[conversation][own_jid].stanza_id == stanza_id; + } + + private void check_add_correction_node(Entities.Message message, Xmpp.MessageStanza message_stanza, Conversation conversation) { + if (message.stanza_id in outstanding_correction_nodes) { + LastMessageCorrection.set_replace_id(message_stanza, outstanding_correction_nodes[message.stanza_id]); + outstanding_correction_nodes.unset(message.stanza_id); + } else { + if (!last_messages.has_key(conversation)) { + last_messages[conversation] = new HashMap(Jid.hash_func, Jid.equals_func); + } + last_messages[conversation][message.from] = message; + } + } + + public string[] after_actions_const = new string[]{ "DEDUPLICATE", "DECRYPT", "FILTER_EMPTY" }; + public override string action_group { get { return "CORRECTION"; } } + public override string[] after_actions { get { return after_actions_const; } } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + string? replace_id = Xep.LastMessageCorrection.get_replace_id(stanza); + if (replace_id == null) { + if (!last_messages.has_key(conversation)) { + last_messages[conversation] = new HashMap(Jid.hash_func, Jid.equals_func); + } + last_messages[conversation][message.from] = message; + + return false; + } + + if (!last_messages.has_key(conversation) || !last_messages[conversation].has_key(message.from)) return false; + Message original_message = last_messages[conversation][message.from]; + if (original_message.stanza_id != replace_id) return false; + + int message_id_to_be_updated = get_latest_correction_message_id(conversation.account.id, replace_id, db.get_jid_id(message.counterpart), message.counterpart.resourcepart); + if (message_id_to_be_updated == -1) { + message_id_to_be_updated = original_message.id; + } + + db.message_correction.insert() + .value(db.message_correction.message_id, message.id) + .value(db.message_correction.to_stanza_id, replace_id) + .perform(); + + int current_correction_message_id = get_latest_correction_message_id(conversation.account.id, replace_id, db.get_jid_id(message.counterpart), message.counterpart.resourcepart); + + if (current_correction_message_id != message_id_to_be_updated) { + db.content_item.update() + .with(db.content_item.foreign_id, "=", message_id_to_be_updated) + .with(db.content_item.content_type, "=", 1) + .set(db.content_item.foreign_id, current_correction_message_id) + .perform(); + message.edit_to = replace_id; + + on_received_correction(conversation, current_correction_message_id); + } + + return true; + } + + private void on_received_correction(Conversation conversation, int message_id) { + ContentItem? content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_item(conversation, 1, message_id); + received_correction(content_item); + } + + private int get_latest_correction_message_id(int account_id, string stanza_id, int counterpart_jid_id, string? counterpart_resource) { + var qry = db.message_correction.select({db.message.id}) + .join_with(db.message, db.message.id, db.message_correction.message_id) + .with(db.message.account_id, "=", account_id) + .with(db.message.counterpart_id, "=", counterpart_jid_id) + .with(db.message_correction.to_stanza_id, "=", stanza_id) + .order_by(db.message.time, "DESC"); + + if (counterpart_resource != null) { + qry.with(db.message.counterpart_resource, "=", counterpart_resource); + } + RowOption row = qry.single().row(); + if (row.is_present()) { + return row[db.message.id]; + } + return -1; + } + + private void on_account_added(Account account) { + Gee.List conversations = stream_interactor.get_module(ConversationManager.IDENTITY).get_active_conversations(account); + foreach (Conversation conversation in conversations) { + if (conversation.type_ != Conversation.Type.CHAT) continue; + + HashMap last_conversation_messages = new HashMap(Jid.hash_func, Jid.equals_func); + Gee.List messages = stream_interactor.get_module(MessageStorage.IDENTITY).get_messages(conversation); + for (int i = messages.size - 1; i > 0; i--) { + Message message = messages[i]; + if (!last_conversation_messages.has_key(message.from) && message.edit_to == null) { + last_conversation_messages[message.from] = message; + } + } + last_messages[conversation] = last_conversation_messages; + } + } +} + +} diff --git a/libdino/src/service/message_processor.vala b/libdino/src/service/message_processor.vala index fd719eda..0120fcd4 100644 --- a/libdino/src/service/message_processor.vala +++ b/libdino/src/service/message_processor.vala @@ -40,6 +40,7 @@ public class MessageProcessor : StreamInteractionModule, Object { received_pipeline.connect(new DeduplicateMessageListener(this, db)); received_pipeline.connect(new FilterMessageListener()); received_pipeline.connect(new StoreMessageListener(stream_interactor)); + received_pipeline.connect(new StoreContentItemListener(stream_interactor)); received_pipeline.connect(new MamMessageListener(stream_interactor)); stream_interactor.account_added.connect(on_account_added); @@ -62,6 +63,7 @@ public class MessageProcessor : StreamInteractionModule, Object { public Entities.Message send_message(Entities.Message message, Conversation conversation) { stream_interactor.get_module(MessageStorage.IDENTITY).add_message(message, conversation); + stream_interactor.get_module(ContentItemStore.IDENTITY).insert_message(message, conversation); send_xmpp_message(message, conversation); message_sent(message, conversation); return message; @@ -526,6 +528,25 @@ public class MessageProcessor : StreamInteractionModule, Object { } } + private class StoreContentItemListener : MessageListener { + + public string[] after_actions_const = new string[]{ "DEDUPLICATE", "DECRYPT", "FILTER_EMPTY", "STORE", "CORRECTION" }; + public override string action_group { get { return "STORE_CONTENT_ITEM"; } } + public override string[] after_actions { get { return after_actions_const; } } + + private StreamInteractor stream_interactor; + + public StoreContentItemListener(StreamInteractor stream_interactor) { + this.stream_interactor = stream_interactor; + } + + public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + if (message.body == null) return true; + stream_interactor.get_module(ContentItemStore.IDENTITY).insert_message(message, conversation); + return false; + } + } + private class MamMessageListener : MessageListener { public string[] after_actions_const = new string[]{ "DEDUPLICATE" }; diff --git a/libdino/src/service/message_storage.vala b/libdino/src/service/message_storage.vala index 9c077109..50fc94b3 100644 --- a/libdino/src/service/message_storage.vala +++ b/libdino/src/service/message_storage.vala @@ -28,7 +28,6 @@ public class MessageStorage : StreamInteractionModule, Object { message.persist(db); init_conversation(conversation); messages[conversation].add(message); - stream_interactor.get_module(ContentItemStore.IDENTITY).insert_message(message, conversation); } public Gee.List get_messages(Conversation conversation, int count = 50) { diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index 0cd76a14..c0bc229e 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -79,6 +79,7 @@ public class ModuleManager { module_map[account].add(new Xep.JingleInBandBytestreams.Module()); module_map[account].add(new Xep.JingleFileTransfer.Module()); module_map[account].add(new Xep.Jet.Module()); + module_map[account].add(new Xep.LastMessageCorrection.Module()); initialize_account_modules(account, module_map[account]); } } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c9e3b879..e2f8ecf8 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -62,6 +62,7 @@ set(RESOURCE_LIST menu_app.ui menu_conversation.ui menu_encryption.ui + message_item_widget_edit_mode.ui occupant_list.ui occupant_list_item.ui search_autocomplete.ui @@ -119,6 +120,7 @@ SOURCES src/ui/add_conversation/select_jid_fragment.vala src/ui/chat_input/chat_input_controller.vala + src/ui/chat_input/chat_text_view.vala src/ui/chat_input/edit_history.vala src/ui/chat_input/encryption_button.vala src/ui/chat_input/occupants_tab_completer.vala @@ -134,12 +136,12 @@ SOURCES src/ui/conversation_selector/conversation_selector.vala src/ui/conversation_content_view/chat_state_populator.vala - src/ui/conversation_content_view/content_item_widget_factory.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 src/ui/conversation_content_view/date_separator_populator.vala src/ui/conversation_content_view/file_widget.vala + src/ui/conversation_content_view/message_widget.vala src/ui/conversation_content_view/subscription_notification.vala src/ui/conversation_titlebar/menu_entry.vala diff --git a/main/data/chat_input.ui b/main/data/chat_input.ui index e47dd4ba..fb004fec 100644 --- a/main/data/chat_input.ui +++ b/main/data/chat_input.ui @@ -45,20 +45,8 @@ - - 300 - true + True - - - True - True - 8 - GTK_WRAP_WORD_CHAR - center - True - - diff --git a/main/data/conversation_content_view/view.ui b/main/data/conversation_content_view/view.ui index af05d285..17f753f5 100644 --- a/main/data/conversation_content_view/view.ui +++ b/main/data/conversation_content_view/view.ui @@ -46,14 +46,12 @@ end start - + False end end - True - - dino-emoticon-add-symbolic + 1 True diff --git a/main/data/message_item_widget_edit_mode.ui b/main/data/message_item_widget_edit_mode.ui new file mode 100644 index 00000000..8a4faca2 --- /dev/null +++ b/main/data/message_item_widget_edit_mode.ui @@ -0,0 +1,70 @@ + + + + + diff --git a/main/data/theme.css b/main/data/theme.css index f72a20d2..44b4b890 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -52,6 +52,14 @@ window.dino-main .dino-sidebar > frame { transition: background .05s ease; } +window.dino-main .dino-conversation .message-box.edit-mode { + background: alpha(@theme_selected_bg_color, 0.1); +} + +window.dino-main .dino-conversation .message-box.edit-mode:hover { + background: alpha(@theme_selected_bg_color, 0.12); +} + window.dino-main .dino-conversation .file-box-outer { background: @theme_base_color; border-radius: 3px; @@ -110,7 +118,9 @@ window.dino-main button.dino-chatinput-button:checked:backdrop { } -.dino-chatinput textview, .dino-chatinput textview text { +.dino-chatinput, +.dino-chatinput textview, +.dino-chatinput textview text { background-color: @theme_base_color; } diff --git a/main/src/ui/chat_input/chat_input_controller.vala b/main/src/ui/chat_input/chat_input_controller.vala index f65da1e8..c0878c36 100644 --- a/main/src/ui/chat_input/chat_input_controller.vala +++ b/main/src/ui/chat_input/chat_input_controller.vala @@ -17,18 +17,20 @@ public class ChatInputController : Object { private StreamInteractor stream_interactor; private Plugins.InputFieldStatus input_field_status; + private ChatTextViewController chat_text_view_controller; public ChatInputController(ChatInput.View chat_input, StreamInteractor stream_interactor) { this.chat_input = chat_input; this.status_description_label = chat_input.chat_input_status; this.stream_interactor = stream_interactor; + this.chat_text_view_controller = new ChatTextViewController(chat_input.chat_text_view, stream_interactor); chat_input.init(stream_interactor); reset_input_field_status(); - chat_input.text_input.buffer.changed.connect(on_text_input_changed); - chat_input.send_text.connect(send_text); + chat_input.chat_text_view.text_view.buffer.changed.connect(on_text_input_changed); + chat_text_view_controller.send_text.connect(send_text); chat_input.encryption_widget.encryption_changed.connect(on_encryption_changed); @@ -40,10 +42,10 @@ public class ChatInputController : Object { reset_input_field_status(); - chat_input.initialize_for_conversation(conversation); - chat_input.occupants_tab_completor.initialize_for_conversation(conversation); - chat_input.edit_history.initialize_for_conversation(conversation); chat_input.encryption_widget.set_conversation(conversation); + + chat_input.initialize_for_conversation(conversation); + chat_text_view_controller.initialize_for_conversation(conversation); } private void on_encryption_changed(Plugins.EncryptionListEntry? encryption_entry) { @@ -81,8 +83,8 @@ public class ChatInputController : Object { return; } - string text = chat_input.text_input.buffer.text; - chat_input.text_input.buffer.text = ""; + string text = chat_input.chat_text_view.text_view.buffer.text; + chat_input.chat_text_view.text_view.buffer.text = ""; if (text.has_prefix("/")) { string[] token = text.split(" ", 2); switch(token[0]) { @@ -137,7 +139,7 @@ public class ChatInputController : Object { } private void on_text_input_changed() { - if (chat_input.text_input.buffer.text != "") { + if (chat_input.chat_text_view.text_view.buffer.text != "") { stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_entered(conversation); } else { stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_cleared(conversation); diff --git a/main/src/ui/chat_input/chat_text_view.vala b/main/src/ui/chat_input/chat_text_view.vala new file mode 100644 index 00000000..1155d21d --- /dev/null +++ b/main/src/ui/chat_input/chat_text_view.vala @@ -0,0 +1,91 @@ +using Gdk; +using Gee; +using Gtk; + +using Dino.Entities; +using Xmpp; + +namespace Dino.Ui { + +public class ChatTextViewController : Object { + + public signal void send_text(); + + public OccupantsTabCompletor occupants_tab_completor; + + private ChatTextView widget; + + public ChatTextViewController(ChatTextView widget, StreamInteractor stream_interactor) { + this.widget = widget; + occupants_tab_completor = new OccupantsTabCompletor(stream_interactor, widget.text_view); + + widget.send_text.connect(() => { + send_text(); + }); + } + + public void initialize_for_conversation(Conversation conversation) { + occupants_tab_completor.initialize_for_conversation(conversation); + widget.initialize_for_conversation(conversation); + } +} + +public class ChatTextView : ScrolledWindow { + + public signal void send_text(); + public signal void cancel_input(); + + public TextView text_view = new TextView() { can_focus=true, hexpand=true, margin=8, wrap_mode=Gtk.WrapMode.WORD_CHAR, valign=Align.CENTER, visible=true }; + private int vscrollbar_min_height; + private SmileyConverter smiley_converter; + public EditHistory edit_history; + + construct { + max_content_height = 300; + propagate_natural_height = true; + this.add(text_view); + + smiley_converter = new SmileyConverter(text_view); + edit_history = new EditHistory(text_view); + + this.get_vscrollbar().get_preferred_height(out vscrollbar_min_height, null); + this.vadjustment.notify["upper"].connect_after(on_upper_notify); + text_view.key_press_event.connect(on_text_input_key_press); + + Gtk.drag_dest_unset(text_view); + } + + public void initialize_for_conversation(Conversation conversation) { + edit_history.initialize_for_conversation(conversation); + } + + public override void get_preferred_height(out int min_height, out int nat_height) { + base.get_preferred_height(out min_height, out nat_height); + min_height = nat_height; + } + + private void on_upper_notify() { + this.vadjustment.value = this.vadjustment.upper - this.vadjustment.page_size; + + // hack for vscrollbar not requiring space and making textview higher //TODO doesn't resize immediately + this.get_vscrollbar().visible = (this.vadjustment.upper > this.max_content_height - 2 * this.vscrollbar_min_height); + } + + private bool on_text_input_key_press(EventKey event) { + if (event.keyval in new uint[]{Key.Return, Key.KP_Enter}) { + if ((event.state & ModifierType.SHIFT_MASK) > 0) { + text_view.buffer.insert_at_cursor("\n", 1); + } else if (text_view.buffer.text != "") { + send_text(); + edit_history.reset_history(); + } + return true; + } + if (event.keyval == Key.Escape) { + cancel_input(); + } + return false; + } +} + +} diff --git a/main/src/ui/chat_input/edit_history.vala b/main/src/ui/chat_input/edit_history.vala index 1d179bb7..70f6d400 100644 --- a/main/src/ui/chat_input/edit_history.vala +++ b/main/src/ui/chat_input/edit_history.vala @@ -4,7 +4,7 @@ using Gtk; using Dino.Entities; -namespace Dino.Ui.ChatInput { +namespace Dino.Ui { public class EditHistory { @@ -14,7 +14,7 @@ public class EditHistory { private HashMap> histories = new HashMap>(Conversation.hash_func, Conversation.equals_func); private HashMap indices = new HashMap(Conversation.hash_func, Conversation.equals_func); - public EditHistory(TextView text_input, GLib.Application application) { + public EditHistory(TextView text_input) { this.text_input = text_input; text_input.key_press_event.connect(on_text_input_key_press); diff --git a/main/src/ui/chat_input/occupants_tab_completer.vala b/main/src/ui/chat_input/occupants_tab_completer.vala index 87db8986..ab1b75e0 100644 --- a/main/src/ui/chat_input/occupants_tab_completer.vala +++ b/main/src/ui/chat_input/occupants_tab_completer.vala @@ -5,7 +5,7 @@ using Gtk; using Dino.Entities; using Xmpp; -namespace Dino.Ui.ChatInput { +namespace Dino.Ui { /** * - With given prefix: Complete from occupant list (sorted lexicographically) diff --git a/main/src/ui/chat_input/smiley_converter.vala b/main/src/ui/chat_input/smiley_converter.vala index a25076ba..8f4dee9a 100644 --- a/main/src/ui/chat_input/smiley_converter.vala +++ b/main/src/ui/chat_input/smiley_converter.vala @@ -4,7 +4,7 @@ using Gtk; using Dino.Entities; -namespace Dino.Ui.ChatInput { +namespace Dino.Ui { class SmileyConverter { diff --git a/main/src/ui/chat_input/view.vala b/main/src/ui/chat_input/view.vala index 960e4e14..166ead2e 100644 --- a/main/src/ui/chat_input/view.vala +++ b/main/src/ui/chat_input/view.vala @@ -10,25 +10,17 @@ namespace Dino.Ui.ChatInput { [GtkTemplate (ui = "/im/dino/Dino/chat_input.ui")] public class View : Box { - public signal void send_text(); - public string text { - owned get { return text_input.buffer.text; } - set { text_input.buffer.text = value; } + owned get { return chat_text_view.text_view.buffer.text; } + set { chat_text_view.text_view.buffer.text = value; } } private StreamInteractor stream_interactor; private Conversation? conversation; private HashMap entry_cache = new HashMap(Conversation.hash_func, Conversation.equals_func); - private int vscrollbar_min_height; - - public OccupantsTabCompletor occupants_tab_completor; - private SmileyConverter smiley_converter; - public EditHistory edit_history; [GtkChild] public Frame frame; - [GtkChild] public ScrolledWindow scrolled; - [GtkChild] public TextView text_input; + [GtkChild] public ChatTextView chat_text_view; [GtkChild] public Box outer_box; [GtkChild] public Button file_button; [GtkChild] public Separator file_separator; @@ -39,9 +31,6 @@ public class View : Box { public View init(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; - occupants_tab_completor = new OccupantsTabCompletor(stream_interactor, text_input); - smiley_converter = new SmileyConverter(text_input); - edit_history = new EditHistory(text_input, GLib.Application.get_default()); encryption_widget = new EncryptionButton(stream_interactor) { relief=ReliefStyle.NONE, margin_top=3, valign=Align.START, visible=true }; file_button.clicked.connect(() => { @@ -53,9 +42,6 @@ public class View : Box { }); file_button.get_style_context().add_class("dino-attach-button"); - scrolled.get_vscrollbar().get_preferred_height(out vscrollbar_min_height, null); - scrolled.vadjustment.notify["upper"].connect_after(on_upper_notify); - encryption_widget.get_style_context().add_class("dino-chatinput-button"); encryption_widget.encryption_changed.connect(update_file_transfer_availability); @@ -68,7 +54,7 @@ public class View : Box { EmojiChooser chooser = new EmojiChooser(); chooser.emoji_picked.connect((emoji) => { - text_input.buffer.insert_at_cursor(emoji, emoji.data.length); + chat_text_view.text_view.buffer.insert_at_cursor(emoji, emoji.data.length); }); emoji_button.set_popover(chooser); @@ -77,8 +63,6 @@ public class View : Box { outer_box.add(encryption_widget); - text_input.key_press_event.connect(on_text_input_key_press); - Util.force_css(frame, "* { border-radius: 3px; }"); return this; @@ -91,17 +75,17 @@ public class View : Box { } public void initialize_for_conversation(Conversation conversation) { - if (this.conversation != null) entry_cache[this.conversation] = text_input.buffer.text; + if (this.conversation != null) entry_cache[this.conversation] = chat_text_view.text_view.buffer.text; this.conversation = conversation; update_file_transfer_availability(); - text_input.buffer.text = ""; + chat_text_view.text_view.buffer.text = ""; if (entry_cache.has_key(conversation)) { - text_input.buffer.text = entry_cache[conversation]; + chat_text_view.text_view.buffer.text = entry_cache[conversation]; } - text_input.grab_focus(); + chat_text_view.text_view.grab_focus(); } public void set_input_state(Plugins.InputFieldStatus.MessageType message_type) { @@ -132,26 +116,6 @@ public class View : Box { return false; }); } - - private bool on_text_input_key_press(EventKey event) { - if (event.keyval in new uint[]{Key.Return, Key.KP_Enter}) { - if ((event.state & ModifierType.SHIFT_MASK) > 0) { - text_input.buffer.insert_at_cursor("\n", 1); - } else if (this.text != "") { - send_text(); - edit_history.reset_history(); - } - return true; - } - return false; - } - - private void on_upper_notify() { - scrolled.vadjustment.value = scrolled.vadjustment.upper - scrolled.vadjustment.page_size; - - // hack for vscrollbar not requiring space and making textview higher //TODO doesn't resize immediately - scrolled.get_vscrollbar().visible = (scrolled.vadjustment.upper > scrolled.max_content_height - 2 * vscrollbar_min_height); - } } } diff --git a/main/src/ui/conversation_content_view/chat_state_populator.vala b/main/src/ui/conversation_content_view/chat_state_populator.vala index 0438e014..545d2c6d 100644 --- a/main/src/ui/conversation_content_view/chat_state_populator.vala +++ b/main/src/ui/conversation_content_view/chat_state_populator.vala @@ -64,10 +64,6 @@ class ChatStatePopulator : Plugins.ConversationItemPopulator, Plugins.Conversati private class MetaChatStateItem : Plugins.MetaConversationItem { public override DateTime sort_time { get; set; default=new DateTime.now_utc().add_years(10); } - public override bool can_merge { get; set; default=false; } - public override bool requires_avatar { get; set; default=false; } - public override bool requires_header { get; set; default=false; } - private StreamInteractor stream_interactor; private Conversation conversation; private Gee.List jids = new ArrayList(); @@ -93,6 +89,8 @@ private class MetaChatStateItem : Plugins.MetaConversationItem { return image_content_box; } + public override Gee.List? get_item_actions(Plugins.WidgetType type) { return null; } + public void set_new(Gee.List jids) { this.jids = jids; update(); diff --git a/main/src/ui/conversation_content_view/content_item_widget_factory.vala b/main/src/ui/conversation_content_view/content_item_widget_factory.vala deleted file mode 100644 index da092e34..00000000 --- a/main/src/ui/conversation_content_view/content_item_widget_factory.vala +++ /dev/null @@ -1,114 +0,0 @@ -using Gee; -using Gdk; -using Gtk; -using Pango; -using Xmpp; - -using Dino.Entities; - -namespace Dino.Ui.ConversationSummary { - -public class ContentItemWidgetFactory : Object { - - private StreamInteractor stream_interactor; - private HashMap generators = new HashMap(); - - public ContentItemWidgetFactory(StreamInteractor stream_interactor) { - this.stream_interactor = stream_interactor; - - generators[MessageItem.TYPE] = new MessageItemWidgetGenerator(stream_interactor); - generators[FileItem.TYPE] = new FileItemWidgetGenerator(stream_interactor); - } - - public Widget? get_widget(ContentItem item) { - WidgetGenerator? generator = generators[item.type_]; - if (generator != null) { - return (Widget?) generator.get_widget(item); - } - return null; - } - - public void register_widget_generator(WidgetGenerator generator) { - generators[generator.handles_type] = generator; - } -} - -public interface WidgetGenerator : Object { - public abstract string handles_type { get; set; } - public abstract Object get_widget(ContentItem item); -} - -public class MessageItemWidgetGenerator : WidgetGenerator, Object { - - public string handles_type { get; set; default=MessageItem.TYPE; } - - private StreamInteractor stream_interactor; - - public MessageItemWidgetGenerator(StreamInteractor stream_interactor) { - this.stream_interactor = stream_interactor; - } - - public Object get_widget(ContentItem item) { - MessageItem message_item = item as MessageItem; - Conversation conversation = message_item.conversation; - Message message = message_item.message; - - Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, vexpand=true, visible=true }; - string markup_text = message.body; - if (markup_text.length > 10000) { - markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; - } - if (message_item.message.body.has_prefix("/me")) { - markup_text = markup_text.substring(3); - } - - if (conversation.type_ == Conversation.Type.GROUPCHAT) { - markup_text = Util.parse_add_markup(markup_text, conversation.nickname, true, true); - } else { - markup_text = Util.parse_add_markup(markup_text, null, true, true); - } - - if (message_item.message.body.has_prefix("/me")) { - string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from); - update_me_style(stream_interactor, message.real_jid ?? message.from, display_name, conversation.account, label, markup_text); - label.realize.connect(() => update_me_style(stream_interactor, message.real_jid ?? message.from, display_name, conversation.account, label, markup_text)); - label.style_updated.connect(() => update_me_style(stream_interactor, message.real_jid ?? message.from, display_name, conversation.account, label, markup_text)); - } - - int only_emoji_count = Util.get_only_emoji_count(markup_text); - if (only_emoji_count != -1) { - string size_str = only_emoji_count < 5 ? "xx-large" : "large"; - markup_text = @"" + markup_text + ""; - } - - label.label = markup_text; - return label; - } - - public static void update_me_style(StreamInteractor stream_interactor, Jid jid, string display_name, Account account, Label label, string action_text) { - string color = Util.get_name_hex_color(stream_interactor, account, jid, Util.is_dark_theme(label)); - label.label = @"$(Markup.escape_text(display_name))" + action_text; - } -} - -public class FileItemWidgetGenerator : WidgetGenerator, Object { - - public StreamInteractor stream_interactor; - public string handles_type { get; set; default=FileItem.TYPE; } - - private const int MAX_HEIGHT = 300; - private const int MAX_WIDTH = 600; - - public FileItemWidgetGenerator(StreamInteractor stream_interactor) { - this.stream_interactor = stream_interactor; - } - - public Object get_widget(ContentItem item) { - FileItem file_item = item as FileItem; - FileTransfer transfer = file_item.file_transfer; - - return new FileWidget(stream_interactor, transfer) { visible=true }; - } -} - -} diff --git a/main/src/ui/conversation_content_view/content_populator.vala b/main/src/ui/conversation_content_view/content_populator.vala index e8eee06c..2a0f8ac1 100644 --- a/main/src/ui/conversation_content_view/content_populator.vala +++ b/main/src/ui/conversation_content_view/content_populator.vala @@ -9,13 +9,11 @@ namespace Dino.Ui.ConversationSummary { public class ContentProvider : ContentItemCollection, Object { private StreamInteractor stream_interactor; - private ContentItemWidgetFactory widget_factory; private Conversation? current_conversation; private Plugins.ConversationItemCollection? item_collection; public ContentProvider(StreamInteractor stream_interactor) { this.stream_interactor = stream_interactor; - this.widget_factory = new ContentItemWidgetFactory(stream_interactor); } public void init(Plugins.ConversationItemCollection item_collection, Conversation conversation, Plugins.WidgetType type) { @@ -28,7 +26,7 @@ public class ContentProvider : ContentItemCollection, Object { } public void insert_item(ContentItem item) { - item_collection.insert_item(new ContentMetaItem(item, widget_factory)); + item_collection.insert_item(create_content_meta_item(item)); } public void remove_item(ContentItem item) { } @@ -38,7 +36,7 @@ public class ContentProvider : ContentItemCollection, Object { Gee.List items = stream_interactor.get_module(ContentItemStore.IDENTITY).get_n_latest(conversation, n); Gee.List ret = new ArrayList(); foreach (ContentItem item in items) { - ret.add(new ContentMetaItem(item, widget_factory)); + ret.add(create_content_meta_item(item)); } return ret; } @@ -47,7 +45,7 @@ public class ContentProvider : ContentItemCollection, Object { Gee.List ret = new ArrayList(); Gee.List items = stream_interactor.get_module(ContentItemStore.IDENTITY).get_before(conversation, before_item, n); foreach (ContentItem item in items) { - ret.add(new ContentMetaItem(item, widget_factory)); + ret.add(create_content_meta_item(item)); } return ret; } @@ -56,26 +54,30 @@ public class ContentProvider : ContentItemCollection, Object { Gee.List ret = new ArrayList(); Gee.List items = stream_interactor.get_module(ContentItemStore.IDENTITY).get_after(conversation, after_item, n); foreach (ContentItem item in items) { - ret.add(new ContentMetaItem(item, widget_factory)); + ret.add(create_content_meta_item(item)); } return ret; } public ContentMetaItem get_content_meta_item(ContentItem content_item) { - return new ContentMetaItem(content_item, widget_factory); + return create_content_meta_item(content_item); + } + + private ContentMetaItem create_content_meta_item(ContentItem content_item) { + if (content_item.type_ == MessageItem.TYPE) { + return new MessageMetaItem(content_item, stream_interactor); + } else if (content_item.type_ == FileItem.TYPE) { + return new FileMetaItem(content_item, stream_interactor); + } + return null; } } -public class ContentMetaItem : Plugins.MetaConversationItem { - public override Jid? jid { get; set; } - public override DateTime sort_time { get; set; } - public override DateTime? display_time { get; set; } - public override Encryption encryption { get; set; } +public abstract class ContentMetaItem : Plugins.MetaConversationItem { public ContentItem content_item; - private ContentItemWidgetFactory widget_factory; - public ContentMetaItem(ContentItem content_item, ContentItemWidgetFactory widget_factory) { + protected ContentMetaItem(ContentItem content_item) { this.jid = content_item.jid; this.sort_time = content_item.sort_time; this.seccondary_sort_indicator = (long) content_item.display_time.to_unix(); @@ -96,15 +98,6 @@ public class ContentMetaItem : Plugins.MetaConversationItem { this.requires_header = true; this.content_item = content_item; - this.widget_factory = widget_factory; - } - - public override bool can_merge { get; set; default=true; } - public override bool requires_avatar { get; set; default=true; } - public override bool requires_header { get; set; default=true; } - - public override Object? get_widget(Plugins.WidgetType type) { - return widget_factory.get_widget(content_item); } } diff --git a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala index 8c59dde7..fe6c2dee 100644 --- a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala +++ b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala @@ -15,6 +15,8 @@ public class ConversationItemSkeleton : EventBox { public StreamInteractor stream_interactor; public Conversation conversation { get; set; } public Plugins.MetaConversationItem item; + public ContentMetaItem? content_meta_item = null; + public Widget? widget = null; private Box image_content_box = new Box(Orientation.HORIZONTAL, 8) { visible=true }; private Box header_content_box = new Box(Orientation.VERTICAL, 0) { visible=true }; @@ -25,9 +27,18 @@ public class ConversationItemSkeleton : EventBox { this.stream_interactor = stream_interactor; this.conversation = conversation; this.item = item; + this.content_meta_item = item as ContentMetaItem; this.get_style_context().add_class("message-box"); - Widget? widget = item.get_widget(Plugins.WidgetType.GTK) as Widget; + item.notify["in-edit-mode"].connect(() => { + if (item.in_edit_mode) { + this.get_style_context().add_class("edit-mode"); + } else { + this.get_style_context().remove_class("edit-mode"); + } + }); + + widget = item.get_widget(Plugins.WidgetType.GTK) as Widget; if (widget != null) { widget.valign = Align.END; header_content_box.add(widget); @@ -51,7 +62,12 @@ public class ConversationItemSkeleton : EventBox { update_margin(); } - public void update_margin() { + public void set_edit_mode() { + if (content_meta_item == null) return; + + } + + private void update_margin() { if (item.requires_header && show_skeleton && metadata_header == null) { metadata_header = new ItemMetaDataHeader(stream_interactor, conversation, item) { visible=true }; header_content_box.add(metadata_header); diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index ac6df1fc..808c6cad 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -15,6 +15,8 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins [GtkChild] public ScrolledWindow scrolled; [GtkChild] private Revealer notification_revealer; [GtkChild] private Box message_menu_box; + [GtkChild] private Button button1; + [GtkChild] private Image button1_icon; [GtkChild] private Box notifications; [GtkChild] private Box main; [GtkChild] private EventBox main_event_box; @@ -29,7 +31,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins private Gee.List item_skeletons = new Gee.ArrayList(); private ContentProvider content_populator; private SubscriptionNotitication subscription_notification; - private bool enable_menu_box = false; private double? was_value; private double? was_upper; @@ -40,8 +41,8 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins private bool firstLoad = true; private bool at_current_content = true; private bool reload_messages = true; - Widget currently_highlighted = null; - ContentItem current_highlighted_item = null; + ConversationItemSkeleton currently_highlighted = null; + ContentMetaItem? current_meta_item = null; bool mouse_inside = false; int last_y_root = -1; @@ -67,6 +68,11 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins main_event_box.events = EventMask.POINTER_MOTION_MASK; main_event_box.motion_notify_event.connect(on_motion_notify_event); + button1.clicked.connect(() => { + current_meta_item.get_item_actions(Plugins.WidgetType.GTK)[0].callback(button1, current_meta_item, currently_highlighted.widget); + update_message_menu(); + }); + return this; } @@ -95,7 +101,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins } last_y_root = y_root; - message_menu_box.visible = enable_menu_box; // Get pointer location in main int geometry_x, geometry_y, geometry_width, geometry_height, dest_x, dest_y; @@ -106,21 +111,24 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins // Get widget under pointer int h = 0; bool @break = false; - Widget? w = null; + ConversationItemSkeleton? w = null; main.@foreach((widget) => { if (break) return; h += widget.get_allocated_height(); - w = widget; + w = widget as ConversationItemSkeleton; if (h >= dest_y) { @break = true; return; } }); + if (currently_highlighted != null) currently_highlighted.unset_state_flags(StateFlags.PRELIGHT); + if (w == null) { - if (currently_highlighted != null) currently_highlighted.unset_state_flags(StateFlags.PRELIGHT); currently_highlighted = null; + current_meta_item = null; + update_message_menu(); return; } @@ -129,23 +137,36 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins w.translate_coordinates(main, 0, 0, out widget_x, out widget_y); // Get MessageItem - var iter = widgets.map_iterator(); - while (iter.next()) { - if (iter.get_value() == w) { - Plugins.MetaConversationItem meta_item = iter.get_key(); - var meta_content_item = meta_item as ContentMetaItem; - if (meta_content_item == null) return; - current_highlighted_item = meta_content_item.content_item; + foreach (Plugins.MetaConversationItem item in item_item_skeletons.keys) { + if (item_item_skeletons[item] == w) { + current_meta_item = item as ContentMetaItem; } } - // Highlight widget - if (currently_highlighted != null) currently_highlighted.unset_state_flags(StateFlags.PRELIGHT); - w.set_state_flags(StateFlags.PRELIGHT, true); - currently_highlighted = w; + update_message_menu(); - // Move message menu - message_menu_box.margin_top = widget_y - 10; + if (current_meta_item != null) { + // Highlight widget + w.set_state_flags(StateFlags.PRELIGHT, true); + currently_highlighted = w; + + // Move message menu + message_menu_box.margin_top = widget_y - 10; + } + } + + private void update_message_menu() { + if (current_meta_item == null) { + message_menu_box.visible = false; + return; + } + + var actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK); + message_menu_box.visible = actions != null && actions.size > 0; + if (actions != null && actions.size == 1) { + button1.visible = true; + button1_icon.set_from_icon_name(actions[0].icon_name, IconSize.SMALL_TOOLBAR); + } } public void initialize_for_conversation(Conversation? conversation) { @@ -163,8 +184,6 @@ public class ConversationView : Box, Plugins.ConversationItemCollection, Plugins initialize_for_conversation_(conversation); display_latest(); stack.set_visible_child_name("main"); - - enable_menu_box = false; } public void initialize_around_message(Conversation conversation, ContentItem content_item) { diff --git a/main/src/ui/conversation_content_view/date_separator_populator.vala b/main/src/ui/conversation_content_view/date_separator_populator.vala index 3ddb0d9a..91485f25 100644 --- a/main/src/ui/conversation_content_view/date_separator_populator.vala +++ b/main/src/ui/conversation_content_view/date_separator_populator.vala @@ -54,10 +54,6 @@ class DateSeparatorPopulator : Plugins.ConversationItemPopulator, Plugins.Conver public class MetaDateItem : Plugins.MetaConversationItem { public override DateTime sort_time { get; set; } - public override bool can_merge { get; set; default=false; } - public override bool requires_avatar { get; set; default=false; } - public override bool requires_header { get; set; default=false; } - private DateTime date; public MetaDateItem(DateTime date) { @@ -76,6 +72,8 @@ public class MetaDateItem : Plugins.MetaConversationItem { return box; } + public override Gee.List? get_item_actions(Plugins.WidgetType type) { return null; } + private static string get_relative_time(DateTime time) { DateTime time_local = time.to_local(); DateTime now_local = new DateTime.now_local(); diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index f5ba08e3..ee14af7a 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -7,6 +7,24 @@ using Dino.Entities; namespace Dino.Ui.ConversationSummary { +public class FileMetaItem : ContentMetaItem { + + private StreamInteractor stream_interactor; + + public FileMetaItem(ContentItem content_item, StreamInteractor stream_interactor) { + base(content_item); + this.stream_interactor = stream_interactor; + } + + public override Object? get_widget(Plugins.WidgetType type) { + FileItem file_item = content_item as FileItem; + FileTransfer transfer = file_item.file_transfer; + return new FileWidget(stream_interactor, transfer) { visible=true }; + } + + public override Gee.List? get_item_actions(Plugins.WidgetType type) { return null; } +} + public class FileWidget : Box { enum State { diff --git a/main/src/ui/conversation_content_view/message_item.vala b/main/src/ui/conversation_content_view/message_item.vala deleted file mode 100644 index e69de29b..00000000 diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala new file mode 100644 index 00000000..71094c71 --- /dev/null +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -0,0 +1,211 @@ +using Gee; +using Gdk; +using Gtk; +using Pango; +using Xmpp; + +using Dino.Entities; + +namespace Dino.Ui.ConversationSummary { + +public class MessageMetaItem : ContentMetaItem { + + private StreamInteractor stream_interactor; + private MessageItemWidget message_item_widget; + private MessageItem message_item; + + public MessageMetaItem(ContentItem content_item, StreamInteractor stream_interactor) { + base(content_item); + message_item = content_item as MessageItem; + this.stream_interactor = stream_interactor; + } + + public override Object? get_widget(Plugins.WidgetType type) { + message_item_widget = new MessageItemWidget(stream_interactor, content_item) { visible=true }; + + message_item_widget.edit_cancelled.connect(() => { this.in_edit_mode = false; }); + message_item_widget.edit_sent.connect(on_edit_send); + + stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.connect(on_received_correction); + + return message_item_widget; + } + + public override Gee.List? get_item_actions(Plugins.WidgetType type) { + if (content_item as FileItem != null) return null; + + bool allowed = stream_interactor.get_module(MessageCorrection.IDENTITY).is_own_correction_allowed(message_item.conversation, message_item.message); + Gee.List actions = new ArrayList(); + if (allowed && !in_edit_mode) { + Plugins.MessageAction action1 = new Plugins.MessageAction(); + action1.icon_name = "document-edit-symbolic"; + action1.callback = (button, content_meta_item_activated, widget) => { + message_item_widget.set_edit_mode(); + this.in_edit_mode = true; + }; + actions.add(action1); + } + return actions; + } + + private void on_edit_send(string text) { + stream_interactor.get_module(MessageCorrection.IDENTITY).send_correction(message_item.conversation, message_item.message, text); + this.in_edit_mode = false; + } + + private void on_received_correction(ContentItem content_item) { + if (this.content_item.id == content_item.id) { + this.content_item = content_item; + message_item = content_item as MessageItem; + message_item_widget.content_item = content_item; + message_item_widget.update_label(); + } + } +} + +public class MessageItemWidget : SizeRequestBin { + + public signal void edit_cancelled(); + public signal void edit_sent(string text); + + StreamInteractor stream_interactor; + public ContentItem content_item; + + Label label = new Label("") { use_markup=true, xalign=0, selectable=true, wrap=true, wrap_mode=Pango.WrapMode.WORD_CHAR, vexpand=true, visible=true }; + MessageItemEditMode? edit_mode = null; + ChatTextViewController? controller = null; + + ulong realize_id = -1; + ulong style_updated_id = -1; + + construct { + this.add(label); + this.size_request_mode = SizeRequestMode.HEIGHT_FOR_WIDTH; + } + + public MessageItemWidget(StreamInteractor stream_interactor, ContentItem content_item) { + this.stream_interactor = stream_interactor; + this.content_item = content_item; + + update_label(); + } + + public void set_edit_mode() { + + MessageItem message_item = content_item as MessageItem; + Message message = message_item.message; + + if (edit_mode == null) { + edit_mode = new MessageItemEditMode(); + controller = new ChatTextViewController(edit_mode.chat_text_view, stream_interactor); + Conversation conversation = message_item.conversation; + controller.initialize_for_conversation(conversation); + + edit_mode.cancelled.connect(() => { + edit_cancelled(); + unset_edit_mode(); + }); + edit_mode.send.connect(() => { + edit_sent(edit_mode.chat_text_view.text_view.buffer.text); + unset_edit_mode(); + }); + } + + edit_mode.chat_text_view.text_view.buffer.text = message.body; + + this.remove(label); + this.add(edit_mode); + + edit_mode.chat_text_view.text_view.grab_focus(); + } + + public void unset_edit_mode() { + this.remove(edit_mode); + this.add(label); + } + + public void update_label() { + label.label = generate_markup_text(content_item); + } + + private string generate_markup_text(ContentItem item) { + MessageItem message_item = item as MessageItem; + Conversation conversation = message_item.conversation; + Message message = message_item.message; + + bool theme_dependent = false; + + string markup_text = message.body; + if (markup_text.length > 10000) { + markup_text = markup_text.substring(0, 10000) + " [" + _("Message too long") + "]"; + } + if (message.body.has_prefix("/me")) { + markup_text = markup_text.substring(3); + } + + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + markup_text = Util.parse_add_markup(markup_text, conversation.nickname, true, true); + } else { + markup_text = Util.parse_add_markup(markup_text, null, true, true); + } + + if (message.body.has_prefix("/me")) { + string display_name = Util.get_participant_display_name(stream_interactor, conversation, message.from); + string color = Util.get_name_hex_color(stream_interactor, conversation.account, message.real_jid ?? message.from, Util.is_dark_theme(label)); + markup_text = @"$(Markup.escape_text(display_name))" + markup_text; + theme_dependent = true; + } + + 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 = @"" + markup_text + ""; + } + + if (message.edit_to != null) { + string color = Util.is_dark_theme(label) ? "#808080" : "#909090"; + markup_text += " (%s)".printf(color, _("edited")); + theme_dependent = true; + } + + if (theme_dependent && realize_id == -1) { + realize_id = label.realize.connect(update_label); + style_updated_id = label.style_updated.connect(update_label); + } else if (!theme_dependent && realize_id != -1) { + label.disconnect(realize_id); + label.disconnect(style_updated_id); + } + return markup_text; + } +} + +[GtkTemplate (ui = "/im/dino/Dino/message_item_widget_edit_mode.ui")] +public class MessageItemEditMode : Box { + + public signal void cancelled(); + public signal void send(); + + [GtkChild] public MenuButton emoji_button; + [GtkChild] public ChatTextView chat_text_view; + [GtkChild] public Button cancel_button; + [GtkChild] public Button send_button; + [GtkChild] public Frame frame; + + construct { + Util.force_css(frame, "* { border-radius: 3px; }"); + + EmojiChooser chooser = new EmojiChooser(); + chooser.emoji_picked.connect((emoji) => { + chat_text_view.text_view.buffer.insert_at_cursor(emoji, emoji.data.length); + }); + emoji_button.set_popover(chooser); + + cancel_button.clicked.connect(() => cancelled()); + send_button.clicked.connect(() => send()); + chat_text_view.cancel_input.connect(() => cancelled()); + chat_text_view.send_text.connect(() => send()); + + } +} + +} diff --git a/main/src/ui/conversation_selector/conversation_selector_row.vala b/main/src/ui/conversation_selector/conversation_selector_row.vala index 6234e9c8..6dc953df 100644 --- a/main/src/ui/conversation_selector/conversation_selector_row.vala +++ b/main/src/ui/conversation_selector/conversation_selector_row.vala @@ -86,6 +86,12 @@ public class ConversationSelectorRow : ListBoxRow { content_item_received(item); } }); + stream_interactor.get_module(MessageCorrection.IDENTITY).received_correction.connect((item) => { + if (last_content_item != null && last_content_item.id == item.id) { + content_item_received(item); + } + }); + last_content_item = stream_interactor.get_module(ContentItemStore.IDENTITY).get_latest(conversation); x_button.clicked.connect(() => { diff --git a/main/src/ui/conversation_view_controller.vala b/main/src/ui/conversation_view_controller.vala index abb8ab57..2e03de8f 100644 --- a/main/src/ui/conversation_view_controller.vala +++ b/main/src/ui/conversation_view_controller.vala @@ -40,7 +40,6 @@ public class ConversationViewController : Object { view.conversation_frame.init(stream_interactor); // drag 'n drop file upload - Gtk.drag_dest_unset(view.chat_input.text_input); Gtk.drag_dest_set(view, DestDefaults.ALL, target_list, Gdk.DragAction.COPY); view.drag_data_received.connect(this.on_drag_data_received); @@ -52,7 +51,9 @@ public class ConversationViewController : Object { // goto-end floating button var vadjustment = view.conversation_frame.scrolled.vadjustment; vadjustment.notify["value"].connect(() => { - view.goto_end_revealer.reveal_child = vadjustment.value < vadjustment.upper - vadjustment.page_size; + bool button_active = vadjustment.value < vadjustment.upper - vadjustment.page_size; + view.goto_end_revealer.reveal_child = button_active; + view.goto_end_revealer.visible = button_active; }); view.goto_end_button.clicked.connect(() => { view.conversation_frame.initialize_for_conversation(conversation); @@ -158,9 +159,11 @@ public class ConversationViewController : Object { if ((event.state & ModifierType.CONTROL_MASK) > 0) { return false; } - view.chat_input.text_input.key_press_event(event); - view.chat_input.text_input.grab_focus(); - return true; + if (view.chat_input.chat_text_view.text_view.key_press_event(event)) { + view.chat_input.chat_text_view.text_view.grab_focus(); + return true; + } + return false; } } } diff --git a/main/src/ui/util/size_request_box.vala b/main/src/ui/util/size_request_box.vala index c9adcb70..a2828262 100644 --- a/main/src/ui/util/size_request_box.vala +++ b/main/src/ui/util/size_request_box.vala @@ -1,11 +1,19 @@ using Gtk; namespace Dino.Ui { -class SizeRequestBox : Box { +public class SizeRequestBox : Box { public SizeRequestMode size_request_mode { get; set; default = SizeRequestMode.CONSTANT_SIZE; } public override Gtk.SizeRequestMode get_request_mode() { return size_request_mode; } } -} \ No newline at end of file + +public class SizeRequestBin : Bin { + public SizeRequestMode size_request_mode { get; set; default = SizeRequestMode.CONSTANT_SIZE; } + + public override Gtk.SizeRequestMode get_request_mode() { + return size_request_mode; + } +} +} diff --git a/plugins/omemo/src/ui/bad_messages_populator.vala b/plugins/omemo/src/ui/bad_messages_populator.vala index 4c72dece..9610d681 100644 --- a/plugins/omemo/src/ui/bad_messages_populator.vala +++ b/plugins/omemo/src/ui/bad_messages_populator.vala @@ -79,9 +79,6 @@ public class BadMessagesPopulator : Plugins.ConversationItemPopulator, Plugins.C } public class BadMessageItem : Plugins.MetaConversationItem { - public override bool can_merge { get; set; default=false; } - public override bool requires_avatar { get; set; default=false; } - public override bool requires_header { get; set; default=false; } private Plugin plugin; private Account account; @@ -101,6 +98,8 @@ public class BadMessageItem : Plugins.MetaConversationItem { public override Object? get_widget(Plugins.WidgetType widget_type) { return new BadMessagesWidget(plugin, account, problem_jid, badness_type); } + + public override Gee.List? get_item_actions(Plugins.WidgetType type) { return null; } } public class BadMessagesWidget : Box { diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 246f0108..6691f950 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -85,6 +85,7 @@ SOURCES "src/module/xep/0260_jingle_socks5_bytestreams.vala" "src/module/xep/0261_jingle_in_band_bytestreams.vala" "src/module/xep/0280_message_carbons.vala" + "src/module/xep/0308_last_message_correction.vala" "src/module/xep/0313_message_archive_management.vala" "src/module/xep/0333_chat_markers.vala" "src/module/xep/0334_message_processing_hints.vala" diff --git a/xmpp-vala/src/module/xep/0308_last_message_correction.vala b/xmpp-vala/src/module/xep/0308_last_message_correction.vala new file mode 100644 index 00000000..7669b244 --- /dev/null +++ b/xmpp-vala/src/module/xep/0308_last_message_correction.vala @@ -0,0 +1,31 @@ +namespace Xmpp.Xep.LastMessageCorrection { + +private const string NS_URI = "urn:xmpp:message-correct:0"; + +public static void set_replace_id(MessageStanza message, string replace_id) { + StanzaNode hint_node = (new StanzaNode.build("replace", NS_URI)).add_self_xmlns().put_attribute("id", replace_id); + message.stanza.put_node(hint_node); +} + +public static string? get_replace_id(MessageStanza message) { + StanzaNode? node = message.stanza.get_subnode("replace", NS_URI); + if (node == null) return null; + + return node.get_attribute("id"); +} + +public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "0308_last_message_correction"); + + public override void attach(XmppStream stream) { + stream.get_module(ServiceDiscovery.Module.IDENTITY).add_feature(stream, NS_URI); + } + + public override void detach(XmppStream stream) {} + + public override string get_ns() { return NS_URI; } + + public override string get_id() { return IDENTITY.id; } +} + +}