Add support for reactions

This commit is contained in:
fiaxh 2022-10-11 13:37:48 +02:00
parent 6c6e7e3aa7
commit 80258a874d
18 changed files with 1026 additions and 23 deletions

View file

@ -51,6 +51,7 @@ SOURCES
src/service/muc_manager.vala src/service/muc_manager.vala
src/service/notification_events.vala src/service/notification_events.vala
src/service/presence_manager.vala src/service/presence_manager.vala
src/service/reactions.vala
src/service/registration.vala src/service/registration.vala
src/service/roster_manager.vala src/service/roster_manager.vala
src/service/search_processor.vala src/service/search_processor.vala

View file

@ -55,6 +55,7 @@ public interface Application : GLib.Application {
EntityInfo.start(stream_interactor, db); EntityInfo.start(stream_interactor, db);
MessageCorrection.start(stream_interactor, db); MessageCorrection.start(stream_interactor, db);
FileTransferStorage.start(stream_interactor, db); FileTransferStorage.start(stream_interactor, db);
Reactions.start(stream_interactor, db);
create_actions(); create_actions();

View file

@ -154,7 +154,8 @@ public interface ConversationItemWidgetInterface: Object {
public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget); public delegate void MessageActionEvoked(Object button, Plugins.MetaConversationItem evoked_on, Object widget);
public class MessageAction : Object { public class MessageAction : Object {
public string icon_name; public string icon_name;
public MessageActionEvoked callback; public Object? popover;
public MessageActionEvoked? callback;
} }
public abstract class MetaConversationNotification : Object { public abstract class MetaConversationNotification : Object {

View file

@ -119,6 +119,20 @@ public class Database : Qlite.Database {
} }
} }
public class OccupantIdTable : Table {
public Column<int> id = new Column.Integer("id") { primary_key = true };
public Column<int> account_id = new Column.Integer("account_id") { not_null = true };
public Column<string> last_nick = new Column.Text("last_nick");
public Column<int> jid_id = new Column.Integer("jid_id");
public Column<string> 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 class UndecryptedTable : Table {
public Column<int> message_id = new Column.Integer("message_id"); public Column<int> message_id = new Column.Integer("message_id");
public Column<int> type_ = new Column.Integer("type"); public Column<int> type_ = new Column.Integer("type");
@ -277,6 +291,23 @@ public class Database : Qlite.Database {
} }
} }
public class ReactionTable : Table {
public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true };
public Column<int> account_id = new Column.Integer("account_id") { not_null = true };
public Column<int> occupant_id = new Column.Integer("occupant_id");
public Column<int> content_item_id = new Column.Integer("content_item_id") { not_null = true };
public Column<long> time = new Column.Long("time") { not_null = true };
public Column<int> jid_id = new Column.Integer("jid_id");
public Column<string> 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 class SettingsTable : Table {
public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true };
public Column<string> key = new Column.Text("key") { unique = true, not_null = true }; public Column<string> 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 MessageTable message { get; private set; }
public MessageCorrectionTable message_correction { get; private set; } public MessageCorrectionTable message_correction { get; private set; }
public RealJidTable real_jid { get; private set; } public RealJidTable real_jid { get; private set; }
public OccupantIdTable occupantid { get; private set; }
public FileTransferTable file_transfer { get; private set; } public FileTransferTable file_transfer { get; private set; }
public CallTable call { get; private set; } public CallTable call { get; private set; }
public CallCounterpartTable call_counterpart { 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 EntityFeatureTable entity_feature { get; private set; }
public RosterTable roster { get; private set; } public RosterTable roster { get; private set; }
public MamCatchupTable mam_catchup { get; private set; } public MamCatchupTable mam_catchup { get; private set; }
public ReactionTable reaction { get; private set; }
public SettingsTable settings { get; private set; } public SettingsTable settings { get; private set; }
public ConversationSettingsTable conversation_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); content_item = new ContentItemTable(this);
message = new MessageTable(this); message = new MessageTable(this);
message_correction = new MessageCorrectionTable(this); message_correction = new MessageCorrectionTable(this);
occupantid = new OccupantIdTable(this);
real_jid = new RealJidTable(this); real_jid = new RealJidTable(this);
file_transfer = new FileTransferTable(this); file_transfer = new FileTransferTable(this);
call = new CallTable(this); call = new CallTable(this);
@ -342,9 +376,10 @@ public class Database : Qlite.Database {
entity_feature = new EntityFeatureTable(this); entity_feature = new EntityFeatureTable(this);
roster = new RosterTable(this); roster = new RosterTable(this);
mam_catchup = new MamCatchupTable(this); mam_catchup = new MamCatchupTable(this);
reaction = new ReactionTable(this);
settings = new SettingsTable(this); settings = new SettingsTable(this);
conversation_settings = new ConversationSettingsTable(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 { try {
exec("PRAGMA journal_mode = WAL"); exec("PRAGMA journal_mode = WAL");

View file

@ -70,6 +70,7 @@ public class ModuleManager {
module_map[account].add(new StreamError.Module()); module_map[account].add(new StreamError.Module());
module_map[account].add(new Xep.InBandRegistration.Module()); module_map[account].add(new Xep.InBandRegistration.Module());
module_map[account].add(new Xep.HttpFileUpload.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.Socks5Bytestreams.Module());
module_map[account].add(new Xep.InBandBytestreams.Module()); module_map[account].add(new Xep.InBandBytestreams.Module());
module_map[account].add(new Xep.Jingle.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.LastMessageCorrection.Module());
module_map[account].add(new Xep.DirectMucInvitations.Module()); module_map[account].add(new Xep.DirectMucInvitations.Module());
module_map[account].add(new Xep.JingleMessageInitiation.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.JingleRawUdp.Module());
module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.Muji.Module());
module_map[account].add(new Xep.CallInvites.Module()); module_map[account].add(new Xep.CallInvites.Module());

View file

@ -28,6 +28,7 @@ public class MucManager : StreamInteractionModule, Object {
private HashMap<Account, BookmarksProvider> bookmarks_provider = new HashMap<Account, BookmarksProvider>(Account.hash_func, Account.equals_func); private HashMap<Account, BookmarksProvider> bookmarks_provider = new HashMap<Account, BookmarksProvider>(Account.hash_func, Account.equals_func);
private HashMap<Account, Gee.List<Jid>> invites = new HashMap<Account, Gee.List<Jid>>(Account.hash_func, Account.equals_func); private HashMap<Account, Gee.List<Jid>> invites = new HashMap<Account, Gee.List<Jid>>(Account.hash_func, Account.equals_func);
public HashMap<Account, Jid> default_muc_server = new HashMap<Account, Jid>(Account.hash_func, Account.equals_func); public HashMap<Account, Jid> default_muc_server = new HashMap<Account, Jid>(Account.hash_func, Account.equals_func);
private HashMap<Account, HashMap<Jid, string>> own_occupant_ids = new HashMap<Account, HashMap<Jid, string>>(Account.hash_func, Account.equals_func);
public static void start(StreamInteractor stream_interactor) { public static void start(StreamInteractor stream_interactor) {
MucManager m = new MucManager(stream_interactor); MucManager m = new MucManager(stream_interactor);
@ -386,6 +387,13 @@ public class MucManager : StreamInteractionModule, Object {
return get_own_jid(jid, account) != null; 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) { 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) => { stream_interactor.module_manager.get_module(account, Xep.Muc.Module.IDENTITY).self_removed_from_room.connect( (stream, jid, code) => {
left(account, jid); left(account, jid);
@ -413,6 +421,12 @@ public class MucManager : StreamInteractionModule, Object {
private_room_occupant_updated(account, room, occupant); 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, string>(Jid.hash_bare_func, Jid.equals_bare_func);
}
own_occupant_ids[account][jid] = occupant_id;
});
} }
private async void search_default_muc_server(Account account) { private async void search_default_muc_server(Account account) {
@ -655,6 +669,7 @@ public class MucManager : StreamInteractionModule, Object {
if (m != null) { if (m != null) {
// For own messages from this device (msg is a duplicate) // For own messages from this device (msg is a duplicate)
m.marked = Message.Marked.RECEIVED; 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) // For own messages from other devices (msg is not a duplicate msg)
message.marked = Message.Marked.RECEIVED; message.marked = Message.Marked.RECEIVED;

View file

@ -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<Reactions> IDENTITY = new ModuleIdentity<Reactions>("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<string, Gee.List<ReactionInfo>> reaction_infos = new HashMap<string, Gee.List<ReactionInfo>>();
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<string> 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<string> 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<ReactionUsers> 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<Jid>? 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<string> 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<string> 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<string>();
}
private class ReactionsTime {
public Gee.List<string>? 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<string>();
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<string>();
ret.time = -1;
}
return ret;
}
private Gee.List<string> string_to_emoji_list(string emoji_str) {
ArrayList<string> ret = new ArrayList<string>();
foreach (string emoji in emoji_str.split(",")) {
if (emoji.length != 0)
ret.add(emoji);
}
return ret;
}
public Gee.List<ReactionUsers> 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<ReactionUsers>();
var index = new HashMap<string, ReactionUsers>();
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>(Jid.equals_func) };
ret.add(index[emoji]);
}
index[emoji].jids.add(jid);
}
}
return ret;
}
public Gee.List<ReactionUsers> 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<ReactionUsers>();
var index = new HashMap<string, ReactionUsers>();
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>(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<string> 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<ReactionInfo>();
}
reaction_infos[message_id].add(reaction_info);
}
private void on_new_message(Message message, Conversation conversation) {
Gee.List<ReactionInfo>? 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<string> 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<string>? 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<string> 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<string> 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<Jid> jids { get; set; }
}
public class Dino.ReactionInfo {
public Account account { get; set; }
public Jid from_jid { get; set; }
public Gee.List<string> reactions { get; set; }
public MessageStanza stanza { get; set; }
public DateTime received_time { get; set; }
}

View file

@ -17,6 +17,7 @@ set(RESOURCE_LIST
dino-conversation-list-placeholder-arrow.svg dino-conversation-list-placeholder-arrow.svg
icons/scalable/actions/dino-account-plus-symbolic.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-emoticon-symbolic.svg
icons/scalable/actions/dino-qr-code-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_image_widget.vala
src/ui/conversation_content_view/file_widget.vala src/ui/conversation_content_view/file_widget.vala
src/ui/conversation_content_view/message_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/conversation_content_view/subscription_notification.vala
src/ui/chat_input/chat_input_controller.vala src/ui/chat_input/chat_input_controller.vala

View file

@ -36,6 +36,9 @@
<property name="margin-end">10</property> <property name="margin-end">10</property>
<property name="halign">end</property> <property name="halign">end</property>
<property name="valign">start</property> <property name="valign">start</property>
<style>
<class name="linked"/>
</style>
<child> <child>
<object class="GtkButton" id="button1"> <object class="GtkButton" id="button1">
<property name="visible">0</property> <property name="visible">0</property>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 23.568 23.711" xmlns="http://www.w3.org/2000/svg">
<path d="m10 19.211c2.33 0 4.3-1.46 5.11-3.5h-10.22c0.8 2.04 2.78 3.5 5.11 3.5m-3.5-6.5c0.82843 0 1.5-0.67157 1.5-1.5s-0.67157-1.5-1.5-1.5-1.5 0.67157-1.5 1.5 0.67157 1.5 1.5 1.5m7 0c0.82843 0 1.5-0.67157 1.5-1.5s-0.67157-1.5-1.5-1.5-1.5 0.67157-1.5 1.5 0.67157 1.5 1.5 1.5m-3.5 9c-4.4183 0-8-3.5817-8-8s3.5817-8 8-8c1.4367-0.016553 1.4581-1.9613 0-2-5.53 0-10 4.5-10 10 0 5.5228 4.4772 10 10 10s10-4.4772 10-10c0-1.0544-2-1.0324-2 0 0 4.4183-3.5817 8-8 8"/>
<path d="m18.908 0c-0.33046 0-0.61972 0.12233-0.80078 0.36133-0.18106 0.239-0.25586 0.56747-0.25586 0.97266v2.6523h-2.2715c-0.38896 0-0.70598 0.064394-0.94727 0.21875-0.26371 0.15646-0.38672 0.4669-0.38672 0.83789 0 0.36179 0.12302 0.66715 0.38086 0.83398l0.001953 0.0019531h0.001953c0.24918 0.15228 0.56865 0.21875 0.94922 0.21875h2.2715v2.6543c0 0.40519 0.074767 0.73218 0.25586 0.9707 0.18109 0.23852 0.46892 0.35944 0.79883 0.36133 0.32038 0.001837 0.60667-0.12152 0.78906-0.35937 0.18239-0.23785 0.26374-0.56552 0.26758-0.9707v-0.0019531-2.6543h2.2559c0.41061 0 0.74047-0.073348 0.98242-0.25391s0.36719-0.46969 0.36719-0.80078c0-0.33109-0.12524-0.62218-0.36719-0.80273s-0.57181-0.25391-0.98242-0.25391h-2.2559v-2.6523c0-0.38896-0.064393-0.70598-0.21875-0.94727-0.15646-0.26371-0.46495-0.38672-0.83594-0.38672z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -82,6 +82,8 @@ window.dino-main .dino-sidebar > frame {
border-bottom: 1px solid @borders; border-bottom: 1px solid @borders;
} }
/* Message */
.message-box { .message-box {
transition: background .05s ease; transition: background .05s ease;
} }
@ -107,6 +109,21 @@ window.dino-main .dino-conversation .message-box.error:hover {
background: alpha(@error_color, 0.12); 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 .file-box-outer,
window.dino-main .call-box-outer { window.dino-main .call-box-outer {
background: @theme_base_color; 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); background: rgba(100, 100, 100, 0.5);
} }
/* Call widget */
window.dino-main .call-box-outer.incoming { window.dino-main .call-box-outer.incoming {
border-color: alpha(@theme_selected_bg_color, 0.3); 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); 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 { window.dino-main .dino-sidebar > frame.collapsed {
border-bottom: 1px solid @borders; border-bottom: 1px solid @borders;
} }
@ -165,6 +217,8 @@ window.dino-main .dino-sidebar frame.auto-complete list > row {
transition: none; transition: none;
} }
/* File overlay */
window.dino-main .dino-white-overlay { window.dino-main .dino-white-overlay {
background: @theme_base_color; background: @theme_base_color;
} }
@ -175,6 +229,8 @@ window.dino-main .dino-file-overlay {
box-shadow: 0 2px 3px alpha(black, 0.1); box-shadow: 0 2px 3px alpha(black, 0.1);
} }
/* Chat Input*/
window.dino-main .dino-chatinput frame box { window.dino-main .dino-chatinput frame box {
background: transparent; background: transparent;
} }

View file

@ -32,6 +32,7 @@ public class ConversationItemSkeleton : Plugins.ConversationItemWidgetInterface,
public Entities.Message.Marked item_mark { get; set; } public Entities.Message.Marked item_mark { get; set; }
public ContentMetaItem content_meta_item = null; public ContentMetaItem content_meta_item = null;
public Widget? widget = null; public Widget? widget = null;
private ReactionsController? reactions_controller = null;
private uint time_update_timeout = 0; private uint time_update_timeout = 0;
private ulong updated_roster_handler_id = 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(update_margin);
this.notify["show-skeleton"].connect(set_header); 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(); update_margin();
} }

View file

@ -15,19 +15,20 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
[GtkChild] public unowned ScrolledWindow scrolled; [GtkChild] public unowned ScrolledWindow scrolled;
[GtkChild] private unowned Revealer notification_revealer; [GtkChild] private unowned Revealer notification_revealer;
[GtkChild] private unowned Box message_menu_box; [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 notifications;
[GtkChild] private unowned Box main; [GtkChild] private unowned Box main;
[GtkChild] private unowned Box main_wrap_box; [GtkChild] private unowned Box main_wrap_box;
[GtkChild] private unowned Stack stack; [GtkChild] private unowned Stack stack;
private ArrayList<Widget> action_buttons = new ArrayList<Widget>();
private Gee.List<Dino.Plugins.MessageAction>? message_actions = null;
private StreamInteractor stream_interactor; private StreamInteractor stream_interactor;
private Gee.TreeSet<Plugins.MetaConversationItem> content_items = new Gee.TreeSet<Plugins.MetaConversationItem>(compare_meta_items); private Gee.TreeSet<Plugins.MetaConversationItem> content_items = new Gee.TreeSet<Plugins.MetaConversationItem>(compare_meta_items);
private Gee.TreeSet<Plugins.MetaConversationItem> meta_items = new TreeSet<Plugins.MetaConversationItem>(compare_meta_items); private Gee.TreeSet<Plugins.MetaConversationItem> meta_items = new TreeSet<Plugins.MetaConversationItem>(compare_meta_items);
private Gee.HashMap<Plugins.MetaConversationItem, ConversationItemSkeleton> item_item_skeletons = new Gee.HashMap<Plugins.MetaConversationItem, ConversationItemSkeleton>(); private Gee.HashMap<Plugins.MetaConversationItem, ConversationItemSkeleton> item_item_skeletons = new Gee.HashMap<Plugins.MetaConversationItem, ConversationItemSkeleton>();
private Gee.HashMap<Plugins.MetaConversationItem, Widget> widgets = new Gee.HashMap<Plugins.MetaConversationItem, Widget>(); private Gee.HashMap<Plugins.MetaConversationItem, Widget> widgets = new Gee.HashMap<Plugins.MetaConversationItem, Widget>();
private Gee.List<ConversationItemSkeleton> item_skeletons = new Gee.ArrayList<ConversationItemSkeleton>(); private Gee.List<Widget> widget_order = new Gee.ArrayList<Widget>();
private ContentProvider content_populator; private ContentProvider content_populator;
private SubscriptionNotitication subscription_notification; private SubscriptionNotitication subscription_notification;
@ -81,11 +82,6 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
main.add_controller(main_motion_events); main.add_controller(main_motion_events);
main_motion_events.motion.connect(update_highlight); 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; 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() { private void on_leave_notify_event() {
if (is_highlight_fixed()) return;
if (currently_highlighted != null) { if (currently_highlighted != null) {
currently_highlighted.remove_css_class("highlight"); currently_highlighted.remove_css_class("highlight");
currently_highlighted = null; currently_highlighted = null;
@ -116,6 +125,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
} }
private void update_highlight(double x, double y) { private void update_highlight(double x, double y) {
if (is_highlight_fixed()) return;
if (currently_highlighted != null && (last_y - y).abs() <= 2) { if (currently_highlighted != null && (last_y - y).abs() <= 2) {
return; return;
} }
@ -174,11 +185,42 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
return; return;
} }
var actions = current_meta_item.get_item_actions(Plugins.WidgetType.GTK4); foreach (Widget widget in action_buttons) {
message_menu_box.visible = actions != null && actions.size > 0; message_menu_box.remove(widget);
if (actions != null && actions.size == 1) { }
button1.visible = true; action_buttons.clear();
button1_icon.set_from_icon_name(actions[0].icon_name);
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) { if (skeleton != null) {
main.remove(skeleton.get_widget()); main.remove(skeleton.get_widget());
widgets.unset(item); widgets.unset(item);
item_skeletons.remove(skeleton); widget_order.remove(skeleton.get_widget());
item_item_skeletons.unset(item); item_item_skeletons.unset(item);
content_items.remove(item); content_items.remove(item);
@ -353,8 +395,8 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
// Fill datastructure // Fill datastructure
ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item, !animate); ConversationItemSkeleton item_skeleton = new ConversationItemSkeleton(stream_interactor, conversation, item, !animate);
item_item_skeletons[item] = item_skeleton; item_item_skeletons[item] = item_skeleton;
int index = lower_item != null ? item_skeletons.index_of(item_item_skeletons[lower_item]) + 1 : 0; int index = lower_item != null ? widget_order.index_of(item_item_skeletons[lower_item].get_widget()) + 1 : 0;
item_skeletons.insert(index, item_skeleton); widget_order.insert(index, item_skeleton.get_widget());
// Insert widget // Insert widget
widgets[item] = item_skeleton.get_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 an item from the past was added, add everything between that item and the (post-)first present item
if (index == 0) { if (index == 0) {
Dino.Application app = Dino.Application.get_default(); 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) { foreach (Plugins.ConversationAdditionPopulator populator in app.plugin_registry.conversation_addition_populators) {
populator.populate_timespan(conversation, item.time, new DateTime.now_utc()); 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); (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() { 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 (was_upper == null || scrolled.vadjustment.value > was_upper - was_page_size - 1) { // scrolled down or content smaller than page size
if (at_current_content) { if (at_current_content) {
@ -471,7 +522,7 @@ public class ConversationView : Widget, Plugins.ConversationItemCollection, Plug
was_page_size = null; was_page_size = null;
content_items.clear(); content_items.clear();
meta_items.clear(); meta_items.clear();
item_skeletons.clear(); widget_order.clear();
item_item_skeletons.clear(); item_item_skeletons.clear();
foreach (Widget widget in widgets.values) { foreach (Widget widget in widgets.values) {
main.remove(widget); main.remove(widget);

View file

@ -22,6 +22,7 @@ public class MessageMetaItem : ContentMetaItem {
MessageItemEditMode? edit_mode = null; MessageItemEditMode? edit_mode = null;
ChatTextViewController? controller = null; ChatTextViewController? controller = null;
private bool supports_reaction = false;
AdditionalInfo additional_info = AdditionalInfo.NONE; AdditionalInfo additional_info = AdditionalInfo.NONE;
ulong realize_id = -1; ulong realize_id = -1;
@ -35,6 +36,8 @@ public class MessageMetaItem : ContentMetaItem {
message_item = content_item as MessageItem; message_item = content_item as MessageItem;
this.stream_interactor = stream_interactor; this.stream_interactor = stream_interactor;
init.begin();
label.activate_link.connect(on_label_activate_link); label.activate_link.connect(on_label_activate_link);
Message message = ((MessageItem) content_item).message; Message message = ((MessageItem) content_item).message;
@ -68,6 +71,10 @@ public class MessageMetaItem : ContentMetaItem {
update_label(); 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) { private string generate_markup_text(ContentItem item) {
MessageItem message_item = item as MessageItem; MessageItem message_item = item as MessageItem;
Conversation conversation = message_item.conversation; Conversation conversation = message_item.conversation;
@ -187,11 +194,13 @@ public class MessageMetaItem : ContentMetaItem {
} }
public override Gee.List<Plugins.MessageAction>? get_item_actions(Plugins.WidgetType type) { public override Gee.List<Plugins.MessageAction>? 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<Plugins.MessageAction> actions = new ArrayList<Plugins.MessageAction>(); Gee.List<Plugins.MessageAction> actions = new ArrayList<Plugins.MessageAction>();
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(); Plugins.MessageAction action1 = new Plugins.MessageAction();
action1.icon_name = "document-edit-symbolic"; action1.icon_name = "document-edit-symbolic";
action1.callback = (button, content_meta_item_activated, widget) => { action1.callback = (button, content_meta_item_activated, widget) => {
@ -199,6 +208,17 @@ public class MessageMetaItem : ContentMetaItem {
}; };
actions.add(action1); 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; return actions;
} }

View file

@ -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<string, Gee.List<Jid>> reactions = new HashMap<string, Gee.List<Jid>>();
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<ReactionUsers> 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>(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<string> update_tooltip(string reaction) {
var name_list = new ArrayList<string>();
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<string, Label> reaction_counts = new HashMap<string, Label>();
private HashMap<string, Button> reaction_buttons = new HashMap<string, Button>();
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<string> names) {
if (!reaction_buttons.has_key(reaction)) {
Label reaction_label = new Label("<span size='small'>" + reaction + "</span>") { 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 = "<span size='small'>" + count.to_string() + "</span>";
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();
}
}
}

View file

@ -141,6 +141,8 @@ SOURCES
"src/module/xep/0380_explicit_encryption.vala" "src/module/xep/0380_explicit_encryption.vala"
"src/module/xep/0391_jingle_encrypted_transports.vala" "src/module/xep/0391_jingle_encrypted_transports.vala"
"src/module/xep/0410_muc_self_ping.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/module/xep/pixbuf_storage.vala"
"src/util.vala" "src/util.vala"

View file

@ -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<Module> IDENTITY = new ModuleIdentity<Module>(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);
}
}
}
}
}

View file

@ -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<Module> IDENTITY = new ModuleIdentity<Module>(NS_URI, "reactions");
public signal void received_reactions(XmppStream stream, Jid from_jid, string message_id, Gee.List<string> 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<string> 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<MessageStanza> {
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<string> reactions = new ArrayList<string>();
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;
}
}
}