From 80258a874ddfeb87b4b71f5791eab94a2465de6d Mon Sep 17 00:00:00 2001 From: fiaxh Date: Tue, 11 Oct 2022 13:37:48 +0200 Subject: Add support for reactions --- libdino/CMakeLists.txt | 1 + libdino/src/application.vala | 1 + libdino/src/plugin/interfaces.vala | 3 +- libdino/src/service/database.vala | 37 +- libdino/src/service/module_manager.vala | 2 + libdino/src/service/muc_manager.vala | 15 + libdino/src/service/reactions.vala | 488 +++++++++++++++++++++ main/CMakeLists.txt | 2 + main/data/conversation_content_view/view.ui | 3 + .../actions/dino-emoticon-add-symbolic.svg | 5 + main/data/theme.css | 56 +++ .../conversation_item_skeleton.vala | 10 + .../conversation_view.vala | 87 +++- .../conversation_content_view/message_widget.vala | 26 +- .../reactions_widget.vala | 192 ++++++++ xmpp-vala/CMakeLists.txt | 2 + xmpp-vala/src/module/xep/0421_occupant_ids.vala | 45 ++ xmpp-vala/src/module/xep/0444_reactions.vala | 74 ++++ 18 files changed, 1026 insertions(+), 23 deletions(-) create mode 100644 libdino/src/service/reactions.vala create mode 100644 main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg create mode 100644 main/src/ui/conversation_content_view/reactions_widget.vala create mode 100644 xmpp-vala/src/module/xep/0421_occupant_ids.vala create mode 100644 xmpp-vala/src/module/xep/0444_reactions.vala diff --git a/libdino/CMakeLists.txt b/libdino/CMakeLists.txt index 6c120346..99c1426f 100644 --- a/libdino/CMakeLists.txt +++ b/libdino/CMakeLists.txt @@ -51,6 +51,7 @@ SOURCES src/service/muc_manager.vala src/service/notification_events.vala src/service/presence_manager.vala + src/service/reactions.vala src/service/registration.vala src/service/roster_manager.vala src/service/search_processor.vala diff --git a/libdino/src/application.vala b/libdino/src/application.vala index 9b36dd79..229a9de1 100644 --- a/libdino/src/application.vala +++ b/libdino/src/application.vala @@ -55,6 +55,7 @@ public interface Application : GLib.Application { EntityInfo.start(stream_interactor, db); MessageCorrection.start(stream_interactor, db); FileTransferStorage.start(stream_interactor, db); + Reactions.start(stream_interactor, db); create_actions(); diff --git a/libdino/src/plugin/interfaces.vala b/libdino/src/plugin/interfaces.vala index e4710732..b3402457 100644 --- a/libdino/src/plugin/interfaces.vala +++ b/libdino/src/plugin/interfaces.vala @@ -154,7 +154,8 @@ public interface ConversationItemWidgetInterface: Object { public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); public class MessageAction : Object { public string icon_name; - public MessageActionEvoked callback; + public Object? popover; + public MessageActionEvoked? callback; } public abstract class MetaConversationNotification : Object { diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index 25a6b477..5f422d2f 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -119,6 +119,20 @@ public class Database : Qlite.Database { } } + public class OccupantIdTable : Table { + public Column id = new Column.Integer("id") { primary_key = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column last_nick = new Column.Text("last_nick"); + public Column jid_id = new Column.Integer("jid_id"); + public Column occupant_id = new Column.Text("occupant_id"); + + internal OccupantIdTable(Database db) { + base(db, "occupant_id"); + init({id, account_id, last_nick, jid_id, occupant_id}); + unique({account_id, jid_id, occupant_id}, "REPLACE"); + } + } + public class UndecryptedTable : Table { public Column message_id = new Column.Integer("message_id"); public Column type_ = new Column.Integer("type"); @@ -277,6 +291,23 @@ public class Database : Qlite.Database { } } + public class ReactionTable : Table { + public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; + public Column account_id = new Column.Integer("account_id") { not_null = true }; + public Column occupant_id = new Column.Integer("occupant_id"); + public Column content_item_id = new Column.Integer("content_item_id") { not_null = true }; + public Column time = new Column.Long("time") { not_null = true }; + public Column jid_id = new Column.Integer("jid_id"); + public Column emojis = new Column.Text("emojis"); + + internal ReactionTable(Database db) { + base(db, "reaction"); + init({id, account_id, occupant_id, content_item_id, time, jid_id, emojis}); + unique({account_id, content_item_id, jid_id}, "REPLACE"); + unique({account_id, content_item_id, occupant_id}, "REPLACE"); + } + } + public class SettingsTable : Table { public Column id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column key = new Column.Text("key") { unique = true, not_null = true }; @@ -308,6 +339,7 @@ public class Database : Qlite.Database { public MessageTable message { get; private set; } public MessageCorrectionTable message_correction { get; private set; } public RealJidTable real_jid { get; private set; } + public OccupantIdTable occupantid { get; private set; } public FileTransferTable file_transfer { get; private set; } public CallTable call { get; private set; } public CallCounterpartTable call_counterpart { get; private set; } @@ -317,6 +349,7 @@ public class Database : Qlite.Database { public EntityFeatureTable entity_feature { get; private set; } public RosterTable roster { get; private set; } public MamCatchupTable mam_catchup { get; private set; } + public ReactionTable reaction { get; private set; } public SettingsTable settings { get; private set; } public ConversationSettingsTable conversation_settings { get; private set; } @@ -332,6 +365,7 @@ public class Database : Qlite.Database { content_item = new ContentItemTable(this); message = new MessageTable(this); message_correction = new MessageCorrectionTable(this); + occupantid = new OccupantIdTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); call = new CallTable(this); @@ -342,9 +376,10 @@ public class Database : Qlite.Database { entity_feature = new EntityFeatureTable(this); roster = new RosterTable(this); mam_catchup = new MamCatchupTable(this); + reaction = new ReactionTable(this); settings = new SettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, message_correction, real_jid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, settings, conversation_settings }); + init({ account, jid, entity, content_item, message, message_correction, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index fc01a687..eeb5369a 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -70,6 +70,7 @@ public class ModuleManager { module_map[account].add(new StreamError.Module()); module_map[account].add(new Xep.InBandRegistration.Module()); module_map[account].add(new Xep.HttpFileUpload.Module()); + module_map[account].add(new Xep.Reactions.Module()); module_map[account].add(new Xep.Socks5Bytestreams.Module()); module_map[account].add(new Xep.InBandBytestreams.Module()); module_map[account].add(new Xep.Jingle.Module()); @@ -80,6 +81,7 @@ public class ModuleManager { module_map[account].add(new Xep.LastMessageCorrection.Module()); module_map[account].add(new Xep.DirectMucInvitations.Module()); module_map[account].add(new Xep.JingleMessageInitiation.Module()); + module_map[account].add(new Xep.OccupantIds.Module()); module_map[account].add(new Xep.JingleRawUdp.Module()); module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.CallInvites.Module()); diff --git a/libdino/src/service/muc_manager.vala b/libdino/src/service/muc_manager.vala index 17787387..4505e992 100644 --- a/libdino/src/service/muc_manager.vala +++ b/libdino/src/service/muc_manager.vala @@ -28,6 +28,7 @@ public class MucManager : StreamInteractionModule, Object { private HashMap bookmarks_provider = new HashMap(Account.hash_func, Account.equals_func); private HashMap> invites = new HashMap>(Account.hash_func, Account.equals_func); public HashMap default_muc_server = new HashMap(Account.hash_func, Account.equals_func); + private HashMap> own_occupant_ids = new HashMap>(Account.hash_func, Account.equals_func); public static void start(StreamInteractor stream_interactor) { MucManager m = new MucManager(stream_interactor); @@ -386,6 +387,13 @@ public class MucManager : StreamInteractionModule, Object { return get_own_jid(jid, account) != null; } + public string? get_own_occupant_id(Account account, Jid muc_jid) { + if (account in own_occupant_ids && muc_jid in own_occupant_ids[account]) { + return own_occupant_ids[account][muc_jid]; + } + return null; + } + private void on_account_added(Account account) { stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).self_removed_from_room.connect( (stream, jid, code) => { left(account, jid); @@ -413,6 +421,12 @@ public class MucManager : StreamInteractionModule, Object { private_room_occupant_updated(account, room, occupant); } }); + stream_interactor.module_manager.get_module(account, Xep.OccupantIds.Module.IDENTITY).received_own_occupant_id.connect( (stream, jid, occupant_id) => { + if (!(account in own_occupant_ids)) { + own_occupant_ids[account] = new HashMap(Jid.hash_bare_func, Jid.equals_bare_func); + } + own_occupant_ids[account][jid] = occupant_id; + }); } private async void search_default_muc_server(Account account) { @@ -655,6 +669,7 @@ public class MucManager : StreamInteractionModule, Object { if (m != null) { // For own messages from this device (msg is a duplicate) m.marked = Message.Marked.RECEIVED; + m.server_id = Xep.UniqueStableStanzaIDs.get_stanza_id(stanza, m.counterpart.bare_jid); } // For own messages from other devices (msg is not a duplicate msg) message.marked = Message.Marked.RECEIVED; diff --git a/libdino/src/service/reactions.vala b/libdino/src/service/reactions.vala new file mode 100644 index 00000000..95bb0fa4 --- /dev/null +++ b/libdino/src/service/reactions.vala @@ -0,0 +1,488 @@ +using Gee; +using Qlite; + +using Xmpp; +using Xmpp.Xep; +using Dino.Entities; + +public class Dino.Reactions : StreamInteractionModule, Object { + public static ModuleIdentity IDENTITY = new ModuleIdentity("reactions"); + public string id { get { return IDENTITY.id; } } + + public signal void reaction_added(Account account, int content_item_id, Jid jid, string reaction); +// [Signal(detailed=true)] + public signal void reaction_removed(Account account, int content_item_id, Jid jid, string reaction); + + private StreamInteractor stream_interactor; + private Database db; + private HashMap> reaction_infos = new HashMap>(); + + public static void start(StreamInteractor stream_interactor, Database database) { + Reactions m = new Reactions(stream_interactor, database); + stream_interactor.add_module(m); + } + + private Reactions(StreamInteractor stream_interactor, Database database) { + this.stream_interactor = stream_interactor; + this.db = database; + stream_interactor.account_added.connect(on_account_added); + + stream_interactor.get_module(MessageProcessor.IDENTITY).message_sent_or_received.connect(on_new_message); + } + + public void add_reaction(Conversation conversation, ContentItem content_item, string reaction) { + Gee.List reactions = get_own_reactions(conversation, content_item); + if (!reactions.contains(reaction)) { + reactions.add(reaction); + } + send_reactions(conversation, content_item, reactions); + reaction_added(conversation.account, content_item.id, conversation.account.bare_jid, reaction); + } + + public void remove_reaction(Conversation conversation, ContentItem content_item, string reaction) { + Gee.List reactions = get_own_reactions(conversation, content_item); + reactions.remove(reaction); + send_reactions(conversation, content_item, reactions); + reaction_removed(conversation.account, content_item.id, conversation.account.bare_jid, reaction); + } + + public Gee.List get_item_reactions(Conversation conversation, ContentItem content_item) { + if (conversation.type_ == Conversation.Type.CHAT) { + return get_chat_message_reactions(conversation.account, content_item); + } else { + return get_muc_message_reactions(conversation.account, content_item); + } + } + + public async bool conversation_supports_reactions(Conversation conversation) { + if (conversation.type_ == Conversation.Type.CHAT) { + Gee.List? resources = stream_interactor.get_module(PresenceManager.IDENTITY).get_full_jids(conversation.counterpart, conversation.account); + if (resources == null) return false; + + foreach (Jid full_jid in resources) { + bool? has_feature = yield stream_interactor.get_module(EntityInfo.IDENTITY).has_feature(conversation.account, full_jid, Xep.Reactions.NS_URI); + if (has_feature == true) { + return true; + } + } + } else { + // The MUC server needs to 1) support stable stanza ids 2) either support occupant ids or be a private room (where we know real jids) + var entity_info = stream_interactor.get_module(EntityInfo.IDENTITY); + bool server_supports_sid = (yield entity_info.has_feature(conversation.account, conversation.counterpart.bare_jid, Xep.UniqueStableStanzaIDs.NS_URI)) || + (yield entity_info.has_feature(conversation.account, conversation.counterpart.bare_jid, Xmpp.MessageArchiveManagement.NS_URI_2)); + if (!server_supports_sid) return false; + + bool? supports_occupant_ids = yield entity_info.has_feature(conversation.account, conversation.counterpart, Xep.OccupantIds.NS_URI); + if (supports_occupant_ids) return true; + + return stream_interactor.get_module(MucManager.IDENTITY).is_private_room(conversation.account, conversation.counterpart); + } + return false; + } + + private void send_reactions(Conversation conversation, ContentItem content_item, Gee.List reactions) { + Message? message = null; + + FileItem? file_item = content_item as FileItem; + if (file_item != null) { + int message_id = int.parse(file_item.file_transfer.info); + message = stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_id(message_id, conversation); + } + MessageItem? message_item = content_item as MessageItem; + if (message_item != null) { + message = message_item.message; + } + + if (message == null) { + return; + } + + XmppStream stream = stream_interactor.get_stream(conversation.account); + if (conversation.type_ == Conversation.Type.GROUPCHAT || conversation.type_ == Conversation.Type.GROUPCHAT_PM) { + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "groupchat", message.server_id ?? message.stanza_id, reactions); + } else if (conversation.type_ == Conversation.Type.GROUPCHAT_PM) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "chat", message.server_id ?? message.stanza_id, reactions); + } + // We save the reaction when it gets reflected back to us + } else if (conversation.type_ == Conversation.Type.CHAT) { + stream.get_module(Xmpp.Xep.Reactions.Module.IDENTITY).send_reaction(stream, conversation.counterpart, "chat", message.stanza_id, reactions); + var datetime_now = new DateTime.now(); + long now_long = (long) (datetime_now.to_unix() * 1000 + datetime_now.get_microsecond()); + save_chat_reactions(conversation.account, conversation.account.bare_jid, content_item.id, now_long, reactions); + } + } + + private Gee.List get_own_reactions(Conversation conversation, ContentItem content_item) { + if (conversation.type_ == Conversation.Type.CHAT) { + return get_chat_user_reactions(conversation.account, content_item.id, conversation.account.bare_jid) + .emojis; + } else if (conversation.type_ == Conversation.Type.GROUPCHAT) { + string own_occupant_id = stream_interactor.get_module(MucManager.IDENTITY).get_own_occupant_id(conversation.account, content_item.jid); + return get_muc_user_reactions(conversation.account, content_item.id, own_occupant_id, conversation.account.bare_jid) + .emojis; + } + return new ArrayList(); + } + + private class ReactionsTime { + public Gee.List? emojis = null; + public long time = -1; + } + + private ReactionsTime get_chat_user_reactions(Account account, int content_item_id, Jid jid) { + int jid_id = db.get_jid_id(jid); + + QueryBuilder query = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item_id) + .with(db.reaction.jid_id, "=", jid_id); + + RowOption row = query.single().row(); + ReactionsTime ret = new ReactionsTime(); + if (row.is_present()) { + ret.emojis = string_to_emoji_list(row[db.reaction.emojis]); + ret.time = row[db.reaction.time]; + } else { + ret.emojis = new ArrayList(); + ret.time = -1; + } + return ret; + } + + private ReactionsTime get_muc_user_reactions(Account account, int content_item_id, string? occupantid, Jid? real_jid) { + QueryBuilder query = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item_id) + .join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id) + .with(db.occupantid.occupant_id, "=", occupantid); + + RowOption row = query.single().row(); + ReactionsTime ret = new ReactionsTime(); + if (row.is_present()) { + ret.emojis = string_to_emoji_list(row[db.reaction.emojis]); + ret.time = row[db.reaction.time]; + } else { + ret.emojis = new ArrayList(); + ret.time = -1; + } + return ret; + } + + private Gee.List string_to_emoji_list(string emoji_str) { + ArrayList ret = new ArrayList(); + foreach (string emoji in emoji_str.split(",")) { + if (emoji.length != 0) + ret.add(emoji); + } + return ret; + } + + public Gee.List get_chat_message_reactions(Account account, ContentItem content_item) { + QueryBuilder select = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item.id) + .order_by(db.reaction.time, "DESC"); + + var ret = new ArrayList(); + var index = new HashMap(); + foreach (Row row in select) { + string emoji_str = row[db.reaction.emojis]; + Jid jid = db.get_jid_by_id(row[db.reaction.jid_id]); + + foreach (string emoji in emoji_str.split(",")) { + if (!index.has_key(emoji)) { + index[emoji] = new ReactionUsers() { reaction=emoji, jids=new ArrayList(Jid.equals_func) }; + ret.add(index[emoji]); + } + index[emoji].jids.add(jid); + } + } + return ret; + } + + public Gee.List get_muc_message_reactions(Account account, ContentItem content_item) { + QueryBuilder select = db.reaction.select() + .with(db.reaction.account_id, "=", account.id) + .with(db.reaction.content_item_id, "=", content_item.id) + .join_with(db.occupantid, db.occupantid.id, db.reaction.occupant_id) + .order_by(db.reaction.time, "DESC"); + + string? own_occupant_id = stream_interactor.get_module(MucManager.IDENTITY).get_own_occupant_id(account, content_item.jid); + + var ret = new ArrayList(); + var index = new HashMap(); + foreach (Row row in select) { + string emoji_str = row[db.reaction.emojis]; + + Jid jid = null; + if (row[db.occupantid.occupant_id] == own_occupant_id) { + jid = account.bare_jid; + } else { + string nick = row[db.occupantid.last_nick]; + jid = content_item.jid.with_resource(nick); + } + + foreach (string emoji in emoji_str.split(",")) { + if (!index.has_key(emoji)) { + index[emoji] = new ReactionUsers() { reaction=emoji, jids=new ArrayList(Jid.equals_func) }; + ret.add(index[emoji]); + } + index[emoji].jids.add(jid); + } + } + return ret; + } + + private void on_account_added(Account account) { + // TODO get time from delays + stream_interactor.module_manager.get_module(account, Xmpp.Xep.Reactions.Module.IDENTITY).received_reactions.connect((stream, from_jid, message_id, reactions, stanza) => { + on_reaction_received.begin(account, from_jid, message_id, reactions, stanza); + }); + } + + private async void on_reaction_received(Account account, Jid from_jid, string message_id, Gee.List reactions, MessageStanza stanza) { + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + // Apply the same restrictions for incoming reactions as we do on sending them + Conversation muc_conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from_jid, account.bare_jid, account, MessageStanza.TYPE_GROUPCHAT); + bool muc_supports_reactions = yield conversation_supports_reactions(muc_conversation); + if (!muc_supports_reactions) return; + } + + Message reaction_message = yield stream_interactor.get_module(MessageProcessor.IDENTITY).parse_message_stanza(account, stanza); + Conversation conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation_for_message(reaction_message); + + Message? message = get_message_for_reaction(conversation, message_id); + var reaction_info = new ReactionInfo() { account=account, from_jid=from_jid, reactions=reactions, stanza=stanza, received_time=new DateTime.now() }; + + if (message != null) { + process_reaction_for_message(message.id, reaction_info); + return; + } + + // Store reaction infos for later processing after we got the message + print(@"Got reaction for $message_id but dont have message yet $(db.get_jid_id(stanza.from.bare_jid))\n"); + if (!reaction_infos.has_key(message_id)) { + reaction_infos[message_id] = new ArrayList(); + } + reaction_infos[message_id].add(reaction_info); + } + + private void on_new_message(Message message, Conversation conversation) { + Gee.List? reaction_info_list = null; + if (conversation.type_ == Conversation.Type.CHAT) { + reaction_info_list = reaction_infos[message.stanza_id]; + } else { + reaction_info_list = reaction_infos[message.server_id]; + } + if (reaction_info_list == null) return; + + // Check if the (or potentially which) reaction fits the message + ReactionInfo? reaction_info = null; + foreach (ReactionInfo info in reaction_info_list) { + if (!info.account.equals(conversation.account)) return; + switch (info.stanza.type_) { + case MessageStanza.TYPE_CHAT: + Jid counterpart = message.from.equals_bare(conversation.account.bare_jid) ? info.stanza.from: info.stanza.to; + if (message.type_ != Message.Type.CHAT || !counterpart.equals_bare(conversation.counterpart)) continue; + break; + case MessageStanza.TYPE_GROUPCHAT: + if (message.type_ != Message.Type.GROUPCHAT || !message.from.equals_bare(conversation.counterpart)) continue; + break; + default: + break; + } + + reaction_info = info; + } + if (reaction_info == null) return; + reaction_info_list.remove(reaction_info); + if (reaction_info_list.is_empty) reaction_infos.unset(message.stanza_id); + + print(@"Got message for reaction\n"); + process_reaction_for_message(message.id, reaction_info); + } + + private Message? get_message_for_reaction(Conversation conversation, string message_id) { + // Query message from a specific account and counterpart. This also makes sure it's a valid reaction for the message. + if (conversation.type_ == Conversation.Type.CHAT) { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_stanza_id(message_id, conversation); + } else { + return stream_interactor.get_module(MessageStorage.IDENTITY).get_message_by_server_id(message_id, conversation); + } + } + + private void process_reaction_for_message(int message_db_id, ReactionInfo reaction_info) { + Account account = reaction_info.account; + MessageStanza stanza = reaction_info.stanza; + Jid from_jid = reaction_info.from_jid; + Gee.List reactions = reaction_info.reactions; + + RowOption file_transfer_row = db.file_transfer.select() + .with(db.file_transfer.account_id, "=", account.id) + .with(db.file_transfer.info, "=", message_db_id.to_string()) + .single().row(); // TODO better + + var content_item_row = db.content_item.select(); + + if (file_transfer_row.is_present()) { + content_item_row.with(db.content_item.foreign_id, "=", file_transfer_row[db.file_transfer.id]) + .with(db.content_item.content_type, "=", 2); + } else { + content_item_row.with(db.content_item.foreign_id, "=", message_db_id) + .with(db.content_item.content_type, "=", 1); + } + var content_item_row_opt = content_item_row.single().row(); + if (!content_item_row_opt.is_present()) return; + int content_item_id = content_item_row_opt[db.content_item.id]; + + // Get reaction time + DateTime? reaction_time = null; + DelayedDelivery.MessageFlag? delayed_message_flag = DelayedDelivery.MessageFlag.get_flag(stanza); + if (delayed_message_flag != null) { + reaction_time = delayed_message_flag.datetime; + } + if (reaction_time == null) { + MessageArchiveManagement.MessageFlag? mam_message_flag = MessageArchiveManagement.MessageFlag.get_flag(stanza); + if (mam_message_flag != null) reaction_time = mam_message_flag.server_time; + } + var time_now = new DateTime.now_local(); + if (reaction_time == null) reaction_time = time_now; + if (reaction_time.compare(time_now) > 0) { + reaction_time = reaction_info.received_time; + } + long reaction_time_long = (long) (reaction_time.to_unix() * 1000 + reaction_time.get_microsecond() / 1000); + + // Get current reactions + string? occupant_id = OccupantIds.get_occupant_id(stanza.stanza); + Jid? real_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(from_jid, account); + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT && occupant_id == null && real_jid == null) { + warning("Attempting to add reaction to message w/o knowing occupant id or real jid"); + return; + } + + ReactionsTime reactions_time = null; + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + reactions_time = get_muc_user_reactions(account, content_item_id, occupant_id, real_jid); + } else if (stanza.type_ == MessageStanza.TYPE_CHAT) { + reactions_time = get_chat_user_reactions(account, content_item_id, from_jid); + } + + if (reaction_time_long <= reactions_time.time) { + // We already have a more recent reaction + return; + } + + // Save reactions + if (stanza.type_ == MessageStanza.TYPE_CHAT) { + save_chat_reactions(account, from_jid, content_item_id, reaction_time_long, reactions); + } else if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT) { + save_muc_reactions(account, content_item_id, from_jid, occupant_id, real_jid, reaction_time_long, reactions); + } + + // Notify about reaction changes + Gee.List? current_reactions = reactions_time.emojis; + + Jid signal_jid = from_jid; + if (stanza.type_ == MessageStanza.TYPE_GROUPCHAT && + signal_jid.equals(stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(from_jid, account))) { + signal_jid = account.bare_jid; + } + + foreach (string current_reaction in current_reactions) { + if (!reactions.contains(current_reaction)) { + reaction_removed(account, content_item_id, signal_jid, current_reaction); + } + } + foreach (string new_reaction in reactions) { + if (!current_reactions.contains(new_reaction)) { + reaction_added(account, content_item_id, signal_jid, new_reaction); + } + } + + print("reactions were: "); + foreach (string reac in current_reactions) { + print(reac + " "); + } + print("\n"); + print("reactions new : "); + foreach (string reac in reactions) { + print(reac + " "); + } + print("\n"); + } + + private void save_chat_reactions(Account account, Jid jid, int content_item_id, long reaction_time, Gee.List reactions) { + var emoji_builder = new StringBuilder(); + for (int i = 0; i < reactions.size; i++) { + if (i != 0) emoji_builder.append(","); + emoji_builder.append(reactions[i]); + } + + db.reaction.upsert() + .value(db.reaction.account_id, account.id, true) + .value(db.reaction.content_item_id, content_item_id, true) + .value(db.reaction.jid_id, db.get_jid_id(jid), true) + .value(db.reaction.emojis, emoji_builder.str, false) + .value(db.reaction.time, reaction_time, false) + .perform(); + } + + private void save_muc_reactions(Account account, int content_item_id, Jid jid, string? occupant_id, Jid? real_jid, long reaction_time, Gee.List reactions) { + assert(occupant_id != null || real_jid != null); + + int jid_id = db.get_jid_id(jid); + + var emoji_builder = new StringBuilder(); + for (int i = 0; i < reactions.size; i++) { + if (i != 0) emoji_builder.append(","); + emoji_builder.append(reactions[i]); + } + + var builder = db.reaction.upsert() + .value(db.reaction.account_id, account.id, true) + .value(db.reaction.content_item_id, content_item_id, true) + .value(db.reaction.emojis, emoji_builder.str, false) + .value(db.reaction.time, reaction_time, false); + + if (real_jid != null) { + builder.value(db.reaction.jid_id, db.get_jid_id(real_jid), occupant_id == null); + } + + if (occupant_id != null) { + RowOption row = db.occupantid.select() + .with(db.occupantid.account_id, "=", account.id) + .with(db.occupantid.jid_id, "=", jid_id) + .with(db.occupantid.occupant_id, "=", occupant_id) + .single().row(); + + int occupant_db_id = -1; + if (row.is_present()) { + occupant_db_id = row[db.occupantid.id]; + } else { + occupant_db_id = (int)db.occupantid.upsert() + .value(db.occupantid.account_id, account.id, true) + .value(db.occupantid.jid_id, jid_id, true) + .value(db.occupantid.occupant_id, occupant_id, true) + .value(db.occupantid.last_nick, jid.resourcepart, false) + .perform(); + } + builder.value(db.reaction.occupant_id, occupant_db_id, true); + } + + builder.perform(); + } +} + +public class Dino.ReactionUsers { + public string reaction { get; set; } + public Gee.List jids { get; set; } +} + +public class Dino.ReactionInfo { + public Account account { get; set; } + public Jid from_jid { get; set; } + public Gee.List reactions { get; set; } + public MessageStanza stanza { get; set; } + public DateTime received_time { get; set; } +} diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f5796651..4fc06339 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -17,6 +17,7 @@ set(RESOURCE_LIST dino-conversation-list-placeholder-arrow.svg icons/scalable/actions/dino-account-plus-symbolic.svg + icons/scalable/actions/dino-emoticon-add-symbolic.svg icons/scalable/actions/dino-emoticon-symbolic.svg icons/scalable/actions/dino-qr-code-symbolic.svg @@ -156,6 +157,7 @@ SOURCES src/ui/conversation_content_view/file_image_widget.vala src/ui/conversation_content_view/file_widget.vala src/ui/conversation_content_view/message_widget.vala + src/ui/conversation_content_view/reactions_widget.vala src/ui/conversation_content_view/subscription_notification.vala src/ui/chat_input/chat_input_controller.vala diff --git a/main/data/conversation_content_view/view.ui b/main/data/conversation_content_view/view.ui index d64c0982..a9aae318 100644 --- a/main/data/conversation_content_view/view.ui +++ b/main/data/conversation_content_view/view.ui @@ -36,6 +36,9 @@ 10 end start + 0 diff --git a/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg b/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg new file mode 100644 index 00000000..51cc75c6 --- /dev/null +++ b/main/data/icons/scalable/actions/dino-emoticon-add-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/main/data/theme.css b/main/data/theme.css index b689d96c..c4bc36c3 100644 --- a/main/data/theme.css +++ b/main/data/theme.css @@ -82,6 +82,8 @@ window.dino-main .dino-sidebar > frame { border-bottom: 1px solid @borders; } +/* Message */ + .message-box { transition: background .05s ease; } @@ -107,6 +109,21 @@ window.dino-main .dino-conversation .message-box.error:hover { background: alpha(@error_color, 0.12); } +/* Message Menu */ + +.message-menu-box { + background-color: @theme_base_color; + border: 1px solid alpha(@theme_fg_color, 0.15); + border-radius: 5px; +} + +.message-menu-button { + padding: 6px; + border: none; +} + +/* Fie Widget */ + window.dino-main .file-box-outer, window.dino-main .call-box-outer { background: @theme_base_color; @@ -140,6 +157,8 @@ window.dino-main .file-image-widget .file-box-outer button:hover { background: rgba(100, 100, 100, 0.5); } +/* Call widget */ + window.dino-main .call-box-outer.incoming { border-color: alpha(@theme_selected_bg_color, 0.3); } @@ -153,6 +172,39 @@ window.dino-main .multiparty-participants { background: alpha(@theme_fg_color, 0.04); } +/* Reactions */ + +window.dino-main button.reaction-box, +window.dino-main menubutton.reaction-box > button { + border: 1px solid transparent; + padding: 3px 5px ; + border-radius: 10px; + background-color: alpha(@theme_fg_color, 0.05); + background-image: none; + box-shadow: none; + min-height: 0; + min-width: 0; +} + +window.dino-main button.reaction-box.own-reaction, +window.dino-main menubutton.reaction-box.own-reaction > button { + color: mix(@theme_selected_bg_color, @theme_fg_color, 0.4); + border-color: @theme_selected_bg_color; + background-color: alpha(@theme_selected_bg_color, 0.05); +} + +window.dino-main button.reaction-box:hover, +window.dino-main menubutton.reaction-box:hover > button { + background-color: alpha(@theme_fg_color, 0.1); +} + +window.dino-main button.reaction-box.own-reaction:hover, +window.dino-main menubutton.reaction-box.own-reaction > button { + background-color: alpha(@theme_selected_bg_color, 0.2); +} + +/* Sidebar */ + window.dino-main .dino-sidebar > frame.collapsed { border-bottom: 1px solid @borders; } @@ -165,6 +217,8 @@ window.dino-main .dino-sidebar frame.auto-complete list > row { transition: none; } +/* File overlay */ + window.dino-main .dino-white-overlay { background: @theme_base_color; } @@ -175,6 +229,8 @@ window.dino-main .dino-file-overlay { box-shadow: 0 2px 3px alpha(black, 0.1); } +/* Chat Input*/ + window.dino-main .dino-chatinput frame box { background: transparent; } 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 e4e6b804..21aca876 100644 --- a/main/src/ui/conversation_content_view/conversation_item_skeleton.vala +++ b/main/src/ui/conversation_content_view/conversation_item_skeleton.vala @@ -32,6 +32,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, public Entities.Message.Marked item_mark { get; set; } public ContentMetaItem content_meta_item = null; public Widget? widget = null; + private ReactionsController? reactions_controller = null; private uint time_update_timeout = 0; private ulong updated_roster_handler_id = 0; @@ -64,6 +65,15 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface, this.notify["show-skeleton"].connect(update_margin); this.notify["show-skeleton"].connect(set_header); + ContentMetaItem? content_meta_item = item as ContentMetaItem; + if (content_meta_item != null) { + reactions_controller = new ReactionsController(conversation, content_meta_item.content_item, stream_interactor); + reactions_controller.box_activated.connect((widget) => { + main_grid.attach(widget, 1, 2, 4, 1); + }); + reactions_controller.init(); + } + update_margin(); } diff --git a/main/src/ui/conversation_content_view/conversation_view.vala b/main/src/ui/conversation_content_view/conversation_view.vala index 4babbdb4..caeee09a 100644 --- a/main/src/ui/conversation_content_view/conversation_view.vala +++ b/main/src/ui/conversation_content_view/conversation_view.vala @@ -15,19 +15,20 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug [GtkChild] public unowned ScrolledWindow scrolled; [GtkChild] private unowned Revealer notification_revealer; [GtkChild] private unowned Box message_menu_box; - [GtkChild] private unowned Button button1; - [GtkChild] private unowned Image button1_icon; [GtkChild] private unowned Box notifications; [GtkChild] private unowned Box main; [GtkChild] private unowned Box main_wrap_box; [GtkChild] private unowned Stack stack; + private ArrayList action_buttons = new ArrayList(); + private Gee.List? message_actions = null; + private StreamInteractor stream_interactor; private Gee.TreeSet content_items = new Gee.TreeSet(compare_meta_items); private Gee.TreeSet meta_items = new TreeSet(compare_meta_items); private Gee.HashMap item_item_skeletons = new Gee.HashMap(); private Gee.HashMap widgets = new Gee.HashMap(); - private Gee.List item_skeletons = new Gee.ArrayList(); + private Gee.List widget_order = new Gee.ArrayList(); private ContentProvider content_populator; private SubscriptionNotitication subscription_notification; @@ -81,11 +82,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug main.add_controller(main_motion_events); main_motion_events.motion.connect(update_highlight); - button1.clicked.connect(() => { - current_meta_item.get_item_actions(Plugins.WidgetType.GTK4)[0].callback(button1, current_meta_item, currently_highlighted); - update_message_menu(); - }); - return this; } @@ -107,7 +103,20 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } } + private bool is_highlight_fixed() { + foreach (Widget widget in action_buttons) { + MenuButton? menu_button = widget as MenuButton; + if (menu_button != null && menu_button.popover.visible) return true; + + ToggleButton? toggle_button = widget as ToggleButton; + if (toggle_button != null && toggle_button.active) return true; + } + return false; + } + private void on_leave_notify_event() { + if (is_highlight_fixed()) return; + if (currently_highlighted != null) { currently_highlighted.remove_css_class("highlight"); currently_highlighted = null; @@ -116,6 +125,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug } private void update_highlight(double x, double y) { + if (is_highlight_fixed()) return; + if (currently_highlighted != null && (last_y - y).abs() <= 2) { return; } @@ -174,11 +185,42 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug return; } - var actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK4); - 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); + foreach (Widget widget in action_buttons) { + message_menu_box.remove(widget); + } + action_buttons.clear(); + + message_actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK4); + + if (message_actions != null) { + message_menu_box.visible = true; + + // Configure as many buttons as we need with the actions for the current meta item + for (int i = 0; i < message_actions.size; i++) { + if (message_actions[i].popover != null) { + MenuButton button = new MenuButton(); + button.icon_name = message_actions[i].icon_name; + button.set_popover(message_actions[i].popover as Popover); + action_buttons.add(button); + } + + if (message_actions[i].callback != null) { + var message_action = message_actions[i]; + Button button = new Button(); + button.icon_name = message_action.icon_name; + button.clicked.connect(() => { + print(@"$(current_meta_item.jid) skdfj \n"); + message_action.callback(button, current_meta_item, currently_highlighted); + }); + action_buttons.add(button); + } + } + + foreach (Widget widget in action_buttons) { + message_menu_box.append(widget); + } + } else { + message_menu_box.visible = false; } } @@ -309,7 +351,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug if (skeleton != null) { main.remove(skeleton.get_widget()); widgets.unset(item); - item_skeletons.remove(skeleton); + widget_order.remove(skeleton.get_widget()); item_item_skeletons.unset(item); content_items.remove(item); @@ -353,8 +395,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // Fill datastructure ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item, !animate); item_item_skeletons[item] = item_skeleton; - int index = lower_item != null ? item_skeletons.index_of(item_item_skeletons[lower_item]) + 1 : 0; - item_skeletons.insert(index, item_skeleton); + int index = lower_item != null ? widget_order.index_of(item_item_skeletons[lower_item].get_widget()) + 1 : 0; + widget_order.insert(index, item_skeleton.get_widget()); // Insert widget widgets[item] = item_skeleton.get_widget(); @@ -382,7 +424,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug // If an item from the past was added, add everything between that item and the (post-)first present item if (index == 0) { Dino.Application app = Dino.Application.get_default(); - if (item_skeletons.size == 1) { + if (widget_order.size == 1) { foreach (Plugins.ConversationAdditionPopulator populator in app.plugin_registry.conversation_addition_populators) { populator.populate_timespan(conversation, item.time, new DateTime.now_utc()); } @@ -404,6 +446,15 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug (upper_item.mark == Message.Marked.WONTSEND) == (lower_item.mark == Message.Marked.WONTSEND); } + private void on_action_button_clicked(ToggleButton button) { + int button_idx = action_buttons.index_of(button); + print(button_idx.to_string() + "\n"); + Plugins.MessageAction message_action = message_actions[button_idx]; + if (message_action.callback != null) { + message_action.callback(button, current_meta_item, currently_highlighted); + } + } + private void on_upper_notify() { if (was_upper == null || scrolled.vadjustment.value > was_upper - was_page_size - 1) { // scrolled down or content smaller than page size if (at_current_content) { @@ -471,7 +522,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug was_page_size = null; content_items.clear(); meta_items.clear(); - item_skeletons.clear(); + widget_order.clear(); item_item_skeletons.clear(); foreach (Widget widget in widgets.values) { main.remove(widget); diff --git a/main/src/ui/conversation_content_view/message_widget.vala b/main/src/ui/conversation_content_view/message_widget.vala index 3da76226..1f027c89 100644 --- a/main/src/ui/conversation_content_view/message_widget.vala +++ b/main/src/ui/conversation_content_view/message_widget.vala @@ -22,6 +22,7 @@ public class MessageMetaItem : ContentMetaItem { MessageItemEditMode? edit_mode = null; ChatTextViewController? controller = null; + private bool supports_reaction = false; AdditionalInfo additional_info = AdditionalInfo.NONE; ulong realize_id = -1; @@ -35,6 +36,8 @@ public class MessageMetaItem : ContentMetaItem { message_item = content_item as MessageItem; this.stream_interactor = stream_interactor; + init.begin(); + label.activate_link.connect(on_label_activate_link); Message message = ((MessageItem) content_item).message; @@ -68,6 +71,10 @@ public class MessageMetaItem : ContentMetaItem { update_label(); } + private async void init() { + supports_reaction = yield stream_interactor.get_module(Reactions.IDENTITY).conversation_supports_reactions(message_item.conversation); + } + private string generate_markup_text(ContentItem item) { MessageItem message_item = item as MessageItem; Conversation conversation = message_item.conversation; @@ -187,11 +194,13 @@ public class MessageMetaItem : ContentMetaItem { } public override Gee.List? get_item_actions(Plugins.WidgetType type) { - if (content_item as FileItem != null) return null; + if (content_item as FileItem != null || this.in_edit_mode) return null; + if (in_edit_mode) 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) { + + bool correction_allowed = stream_interactor.get_module(MessageCorrection.IDENTITY).is_own_correction_allowed(message_item.conversation, message_item.message); + if (correction_allowed) { Plugins.MessageAction action1 = new Plugins.MessageAction(); action1.icon_name = "document-edit-symbolic"; action1.callback = (button, content_meta_item_activated, widget) => { @@ -199,6 +208,17 @@ public class MessageMetaItem : ContentMetaItem { }; actions.add(action1); } + + if (supports_reaction) { + Plugins.MessageAction action2 = new Plugins.MessageAction(); + action2.icon_name = "dino-emoticon-add-symbolic"; + EmojiChooser chooser = new EmojiChooser(); + chooser.emoji_picked.connect((emoji) => { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(message_item.conversation, message_item, emoji); + }); + action2.popover = chooser; + actions.add(action2); + } return actions; } diff --git a/main/src/ui/conversation_content_view/reactions_widget.vala b/main/src/ui/conversation_content_view/reactions_widget.vala new file mode 100644 index 00000000..83b3204e --- /dev/null +++ b/main/src/ui/conversation_content_view/reactions_widget.vala @@ -0,0 +1,192 @@ +using Gee; +using Gtk; + +using Dino.Entities; +using Xmpp; + +namespace Dino.Ui.ConversationSummary { + +public class ReactionsController : Object { + public signal void box_activated(Widget widget); + + private Conversation conversation; + private Account account; + private ContentItem content_item; + private StreamInteractor stream_interactor; + + private HashMap> reactions = new HashMap>(); + + private ReactionsWidget? widget = null; + + public ReactionsController(Conversation conversation, ContentItem content_item, StreamInteractor stream_interactor) { + this.conversation = conversation; + this.account = conversation.account; + this.content_item = content_item; + this.stream_interactor = stream_interactor; + } + + public void init() { + Gee.List reactions = stream_interactor.get_module(Reactions.IDENTITY).get_item_reactions(conversation, content_item); + if (reactions.size > 0) { + initialize_widget(); + } + foreach (ReactionUsers reaction_users in reactions) { + foreach (Jid jid in reaction_users.jids) { + reaction_added(reaction_users.reaction, jid); + } + } + + stream_interactor.get_module(Reactions.IDENTITY).reaction_added.connect((account, content_item_id, jid, reaction) => { + if (this.content_item.id == content_item_id) { + reaction_added(reaction, jid); + } + }); + stream_interactor.get_module(Reactions.IDENTITY).reaction_removed.connect((account, content_item_id, jid, reaction) => { + if (this.content_item.id == content_item_id) { + reaction_removed(reaction, jid); + } + }); + } + + private void initialize_widget() { + widget = new ReactionsWidget() { visible=true }; + widget.emoji_picked.connect((emoji) => { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(conversation, content_item, emoji); + }); + widget.emoji_clicked.connect((emoji) => { + if (account.bare_jid in reactions[emoji]) { + stream_interactor.get_module(Reactions.IDENTITY).remove_reaction(conversation, content_item, emoji); + } else { + stream_interactor.get_module(Reactions.IDENTITY).add_reaction(conversation, content_item, emoji); + } + }); + box_activated(widget); + } + + public void reaction_added(string reaction, Jid jid) { + if (widget == null) { + initialize_widget(); + } + + if (!reactions.has_key(reaction)) { + reactions[reaction] = new ArrayList(Jid.equals_func); + } + if (jid.equals_bare(account.bare_jid) && reactions[reaction].contains(jid)) { + return; + } + reactions[reaction].add(jid); + + if (reactions[reaction].size == 0) return; + + widget.update_reaction(reaction, reactions[reaction].size, reactions[reaction].contains(account.bare_jid), update_tooltip(reaction)); + } + + public void reaction_removed(string reaction, Jid jid) { + if (!reactions.has_key(reaction)) return; + reactions[reaction].remove(jid); + + if (reactions[reaction].size > 0) { + widget.update_reaction(reaction, reactions[reaction].size, reactions[reaction].contains(account.bare_jid), update_tooltip(reaction)); + } else { + widget.remove_reaction(reaction); + reactions.unset(reaction); + } + + if (reactions.size == 0) { + widget.unparent(); + widget = null; + } + } + + private Gee.List update_tooltip(string reaction) { + var name_list = new ArrayList(); + if (reactions[reaction].size > 0) { + if (account.bare_jid in reactions[reaction]) { + name_list.add(_("You")); + } + foreach (Jid jid in reactions[reaction]) { + if (jid.equals(account.bare_jid)) continue; + + name_list.add(Util.get_participant_display_name(stream_interactor, conversation, jid)); + } + } + return name_list; + } +} + +public class ReactionsWidget : Grid { + + public signal void emoji_picked(string emoji); + public signal void emoji_clicked(string emoji); + + private HashMap reaction_counts = new HashMap(); + private HashMap reaction_buttons = new HashMap(); + private MenuButton add_button; + + public ReactionsWidget() { + this.row_spacing = this.column_spacing = 5; + this.margin_top = 2; + + add_button = new MenuButton() { tooltip_text= _("Add reaction"), visible=true }; + add_button.get_style_context().add_class("reaction-box"); + Image add_image = new Image.from_icon_name("dino-emoticon-add-symbolic") { margin_start=5, margin_end=5, visible=true }; + add_button.set_child(add_image); + + EmojiChooser chooser = new EmojiChooser(); + chooser.emoji_picked.connect((emoji) => { + emoji_picked(emoji); + }); + add_button.set_popover(chooser); + } + + public void update_reaction(string reaction, int count, bool own, Gee.List names) { + if (!reaction_buttons.has_key(reaction)) { + Label reaction_label = new Label("" + reaction + "") { use_markup=true, visible=true }; + Label count_label = new Label("") { use_markup=true, visible=true }; + Button button = new Button() { visible=true }; + button.get_style_context().add_class("reaction-box"); + Box reaction_box = new Box(Orientation.HORIZONTAL, 4) { visible=true }; + reaction_box.append(reaction_label); + reaction_box.append(count_label); + button.set_child(reaction_box); + + reaction_counts[reaction] = count_label; + reaction_buttons[reaction] = button; + + this.attach(button, (reaction_buttons.size - 1) % 10, (reaction_buttons.size - 1) / 10, 1, 1); + if (add_button.get_parent() != null) this.remove(add_button); + this.attach(add_button, reaction_buttons.size % 10, reaction_buttons.size / 10, 1, 1); + + + button.clicked.connect(() => { + emoji_clicked(reaction); + }); + } + + reaction_counts[reaction].label = "" + count.to_string() + ""; + if (own) { + reaction_buttons[reaction].get_style_context().add_class("own-reaction"); + } else { + reaction_buttons[reaction].get_style_context().remove_class("own-reaction"); + } + + // Build tooltip + StringBuilder tooltip_builder = new StringBuilder (); + for (int i = 0; i < names.size - 1; i++) { + tooltip_builder.append(names[i]); + if (i < names.size - 2) tooltip_builder.append(", "); + } + if (names.size > 1) { + tooltip_builder.append(" and "); + } + tooltip_builder.append(names[names.size - 1]); + tooltip_builder.append(" reacted with " + reaction); + reaction_buttons[reaction].set_tooltip_text(tooltip_builder.str); + } + + public void remove_reaction(string reaction) { + reaction_buttons[reaction].unparent(); + } +} + +} \ No newline at end of file diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 4ad7f0e9..de89f326 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -141,6 +141,8 @@ SOURCES "src/module/xep/0380_explicit_encryption.vala" "src/module/xep/0391_jingle_encrypted_transports.vala" "src/module/xep/0410_muc_self_ping.vala" + "src/module/xep/0421_occupant_ids.vala" + "src/module/xep/0444_reactions.vala" "src/module/xep/pixbuf_storage.vala" "src/util.vala" diff --git a/xmpp-vala/src/module/xep/0421_occupant_ids.vala b/xmpp-vala/src/module/xep/0421_occupant_ids.vala new file mode 100644 index 00000000..ce9f2471 --- /dev/null +++ b/xmpp-vala/src/module/xep/0421_occupant_ids.vala @@ -0,0 +1,45 @@ +namespace Xmpp.Xep.OccupantIds { + +public const string NS_URI = "urn:xmpp:occupant-id:0"; + +public static string? get_occupant_id(StanzaNode stanza) { + StanzaNode? node = stanza.get_subnode("occupant-id", NS_URI); + if (node == null) return null; + + return node.get_attribute("id"); +} + +public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "0421_occupant_ids"); + + public signal void received_occupant_id(XmppStream stream, Jid jid, string occupant_id); + public signal void received_own_occupant_id(XmppStream stream, Jid jid, string occupant_id); + + public override void attach(XmppStream stream) { + stream.get_module(Presence.Module.IDENTITY).received_available.connect(parse_occupant_id_from_presence); + } + + public override void detach(XmppStream stream) { + stream.get_module(Presence.Module.IDENTITY).received_available.disconnect(parse_occupant_id_from_presence); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + + public void parse_occupant_id_from_presence(XmppStream stream, Presence.Stanza presence) { + string? occupant_id = get_occupant_id(presence.stanza); + if (occupant_id == null) return; + + received_occupant_id(stream, presence.from, occupant_id); + + StanzaNode? x_node = presence.stanza.get_subnode("x", "http://jabber.org/protocol/muc#user"); + if (x_node == null) return; + foreach (StanzaNode status_node in x_node.get_subnodes("status", "http://jabber.org/protocol/muc#user")) { + if (int.parse(status_node.get_attribute("code")) == 110) { + received_own_occupant_id(stream, presence.from, occupant_id); + } + } + } +} + +} diff --git a/xmpp-vala/src/module/xep/0444_reactions.vala b/xmpp-vala/src/module/xep/0444_reactions.vala new file mode 100644 index 00000000..90d922d1 --- /dev/null +++ b/xmpp-vala/src/module/xep/0444_reactions.vala @@ -0,0 +1,74 @@ +using Gee; + +namespace Xmpp.Xep.Reactions { + +public const string NS_URI = "urn:xmpp:reactions:0"; + +public class Module : XmppStreamModule { + public static ModuleIdentity IDENTITY = new ModuleIdentity(NS_URI, "reactions"); + + public signal void received_reactions(XmppStream stream, Jid from_jid, string message_id, Gee.List reactions, MessageStanza stanza); + + private ReceivedPipelineListener received_pipeline_listener = new ReceivedPipelineListener(); + + public void send_reaction(XmppStream stream, Jid jid, string stanza_type, string message_id, Gee.List reactions) { + StanzaNode reactions_node = new StanzaNode.build("reactions", NS_URI).add_self_xmlns(); + reactions_node.put_attribute("to", message_id); + foreach (string reaction in reactions) { + StanzaNode reaction_node = new StanzaNode.build("reaction", NS_URI); + reaction_node.put_node(new StanzaNode.text(reaction)); + reactions_node.put_node(reaction_node); + } + + MessageStanza message = new MessageStanza() { to=jid, type_=stanza_type }; + message.stanza.put_node(reactions_node); + + MessageProcessingHints.set_message_hint(message, MessageProcessingHints.HINT_STORE); + + stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, message); + } + + public override void attach(XmppStream stream) { + stream.get_module(ServiceDiscovery.Module.IDENTITY).add_feature(stream, NS_URI); + stream.get_module(MessageModule.IDENTITY).received_pipeline.connect(received_pipeline_listener); + } + + public override void detach(XmppStream stream) { + stream.get_module(ServiceDiscovery.Module.IDENTITY).remove_feature(stream, NS_URI); + stream.get_module(MessageModule.IDENTITY).received_pipeline.disconnect(received_pipeline_listener); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } +} + +public class ReceivedPipelineListener : StanzaListener { + + private const string[] after_actions_const = {"EXTRACT_MESSAGE_2"}; + + public override string action_group { get { return ""; } } + public override string[] after_actions { get { return after_actions_const; } } + + public override async bool run(XmppStream stream, MessageStanza message) { + StanzaNode? reactions_node = message.stanza.get_subnode("reactions", NS_URI); + if (reactions_node == null) return false; + + string? to_attribute = reactions_node.get_attribute("to"); + if (to_attribute == null) return false; + + Gee.List reactions = new ArrayList(); + foreach (StanzaNode reaction_node in reactions_node.get_subnodes("reaction", NS_URI)) { + string? reaction = reaction_node.get_string_content(); + if (reaction == null) return false; + + if (!reactions.contains(reaction)) { + reactions.add(reaction); + } + } + stream.get_module(Module.IDENTITY).received_reactions(stream, message.from, to_attribute, reactions, message); + + return false; + } +} + +} -- cgit v1.2.3-70-g09d2