From 7adcfb336543271f3cc848cd07acb44b0a78dd27 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 13 Nov 2024 21:44:01 -0500 Subject: [PATCH] Store timestamp/alt text with reactions Also fixes a bug where sending a new set of emoji reaction would erase any custom emoji reaction. Sending a new set of emoji reaction will still erase any append-style emoji reaction but the assumption is you wanted that since you sent a whole new set of emoji. But custom can only be sent as append. --- Makefile | 2 ++ npm/index.ts | 3 ++ snikket/Chat.hx | 21 ++++++++---- snikket/ChatMessage.hx | 6 ++-- snikket/Message.hx | 18 +++++----- snikket/Reaction.hx | 36 +++++++++++++++++++ snikket/ReactionUpdate.hx | 55 ++++++++++++++++++++--------- snikket/persistence/browser.js | 63 ++++++++++++++++++++++++++-------- 8 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 snikket/Reaction.hx diff --git a/Makefile b/Makefile index d3cdf18..6e79179 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ npm/snikket-browser.js: sed -i 's/snikket\.MessageType/enums.MessageType/g' npm/snikket-browser.d.ts sed -i 's/snikket\.UserState/enums.UserState/g' npm/snikket-browser.d.ts sed -i 's/snikket\.ChatMessageEvent/enums.ChatMessageEvent/g' npm/snikket-browser.d.ts + sed -i 's/snikket\.ReactionUpdateKind/enums.ReactionUpdateKind/g' npm/snikket-browser.d.ts sed -i 's/_Push.Push_Fields_/Push/g' npm/snikket-browser.d.ts sed -i '1ivar exports = {};' npm/snikket-browser.js echo "export const snikket = exports.snikket;" >> npm/snikket-browser.js @@ -29,6 +30,7 @@ npm/snikket.js: sed -i 's/snikket\.MessageType/enums.MessageType/g' npm/snikket.d.ts sed -i 's/snikket\.UserState/enums.UserState/g' npm/snikket.d.ts sed -i 's/snikket\.ChatMessageEvent/enums.ChatMessageEvent/g' npm/snikket.d.ts + sed -i 's/snikket\.ReactionUpdateKind/enums.ReactionUpdateKind/g' npm/snikket.d.ts sed -i 's/_Push.Push_Fields_/Push/g' npm/snikket.d.ts sed -i '1iimport { createRequire } from "module";' npm/snikket.js sed -i '1iglobal.require = createRequire(import.meta.url);' npm/snikket.js diff --git a/npm/index.ts b/npm/index.ts index 582518f..c6c0009 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -11,6 +11,7 @@ export import ChatAttachment = snikket.ChatAttachment; export import ChatMessage = snikket.ChatMessage; export import Client = snikket.Client; export import Config = snikket.Config; +export import CustomEmojiReaction = snikket.CustomEmojiReaction; export import DirectChat = snikket.DirectChat; export import Hash = snikket.Hash; export import Identicon = snikket.Identicon; @@ -18,6 +19,7 @@ export import Identity = snikket.Identity; export import Notification = snikket.Notification; export import Participant = snikket.Participant; export import Push = snikket.Push; +export import Reaction = snikket.Reaction; export import SerializedChat = snikket.SerializedChat; export import jingle = snikket.jingle; export const VERSION = snikket.Version.HUMAN; @@ -26,6 +28,7 @@ export import ChatMessageEvent = enums.ChatMessageEvent; export import MessageDirection = enums.MessageDirection; export import MessageStatus = enums.MessageStatus; export import MessageType = enums.MessageType; +export import ReactionUpdateKind = enums.ReactionUpdateKind; export import UiState = enums.UiState; export import UserState = enums.UserState; diff --git a/snikket/Chat.hx b/snikket/Chat.hx index 8c5b579..8e87428 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -9,6 +9,7 @@ import snikket.GenericStream; import snikket.ID; import snikket.Message; import snikket.MessageSync; +import snikket.Reaction; import snikket.jingle.PeerConnection; import snikket.jingle.Session; import snikket.queries.DiscoInfoGet; @@ -768,10 +769,15 @@ class DirectChat extends Chat { public function removeReaction(m:ChatMessage, reaction:String) { // NOTE: doing it this way means no fallback behaviour final reactions = []; - for (areaction => senders in m.reactions) { - if (areaction != reaction && senders.contains(client.accountId())) reactions.push(areaction); + for (areaction => reacts in m.reactions) { + if (areaction != reaction) { + final react = reacts.find(r -> r.senderId == client.accountId()); + if (react != null && !Std.is(react, CustomEmojiReaction)) { + reactions.push(react); + } + } } - final update = new ReactionUpdate(ID.long(), null, null, m.localId, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions); + final update = new ReactionUpdate(ID.long(), null, null, m.localId, m.chatId(), client.accountId(), Date.format(std.Date.now()), reactions, EmojiReactions); persistence.storeReaction(client.accountId(), update, (stored) -> { final stanza = update.asStanza(); for (recipient in getParticipants()) { @@ -1194,10 +1200,13 @@ class Channel extends Chat { public function removeReaction(m:ChatMessage, reaction:String) { // NOTE: doing it this way means no fallback behaviour final reactions = []; - for (areaction => senders in m.reactions) { - if (areaction != reaction && senders.contains(getFullJid().asString())) reactions.push(areaction); + for (areaction => reacts in m.reactions) { + if (areaction != reaction) { + final react = reacts.find(r -> r.senderId == getFullJid().asString()); + if (react != null && !Std.is(react, CustomEmojiReaction)) reactions.push(react); + } } - final update = new ReactionUpdate(ID.long(), m.serverId, m.chatId(), null, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions); + final update = new ReactionUpdate(ID.long(), m.serverId, m.chatId(), null, m.chatId(), getFullJid().asString(), Date.format(std.Date.now()), reactions, EmojiReactions); persistence.storeReaction(client.accountId(), update, (stored) -> { final stanza = update.asStanza(); stanza.attr.set("to", chatId); diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx index dd22b24..1234d9e 100644 --- a/snikket/ChatMessage.hx +++ b/snikket/ChatMessage.hx @@ -113,7 +113,7 @@ class ChatMessage { Map of reactions to this message **/ @HaxeCBridge.noemit - public var reactions: Map> = []; + public var reactions: Map> = []; /** Body text of this message or NULL @@ -435,8 +435,8 @@ class ChatMessage { stanza.textTag("reaction", reaction); addedReactions[reaction] = true; - for (areaction => senders in replyToM.reactions) { - if (!(addedReactions[areaction] ?? false) && senders.contains(senderId())) { + for (areaction => reactions in replyToM.reactions) { + if (!(addedReactions[areaction] ?? false) && reactions.find(r -> r.senderId == senderId()) != null) { addedReactions[areaction] = true; stanza.textTag("reaction", areaction); } diff --git a/snikket/Message.hx b/snikket/Message.hx index 4882067..72e4613 100644 --- a/snikket/Message.hx +++ b/snikket/Message.hx @@ -1,5 +1,6 @@ package snikket; +import snikket.Reaction; using Lambda; using StringTools; @@ -166,9 +167,10 @@ class Message { isGroupchat ? msg.chatId() : null, isGroupchat ? null : reactionId, msg.chatId(), - timestamp, msg.senderId(), - reactions + timestamp, + reactions.map(text -> new Reaction(msg.senderId(), timestamp, text)), + EmojiReactions ))); } } @@ -225,10 +227,10 @@ class Message { isGroupchat ? msg.chatId() : null, isGroupchat ? null : replyToID, msg.chatId(), - timestamp, msg.senderId(), - [text.trim()], - true + timestamp, + [new Reaction(msg.senderId(), timestamp, text.trim())], + AppendReactions ))); } @@ -245,10 +247,10 @@ class Message { isGroupchat ? msg.chatId() : null, isGroupchat ? null : replyToID, msg.chatId(), - timestamp, msg.senderId(), - [hash.serializeUri()], - true + timestamp, + [new CustomEmojiReaction(msg.senderId(), timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri())], + AppendReactions ))); } } diff --git a/snikket/Reaction.hx b/snikket/Reaction.hx new file mode 100644 index 0000000..be6947a --- /dev/null +++ b/snikket/Reaction.hx @@ -0,0 +1,36 @@ +package snikket; + +@:nullSafety(Strict) +@:expose +class Reaction { + public final senderId: String; + public final timestamp: String; + public final text: String; + public final key: String; + + public function new(senderId: String, timestamp: String, text: String, key: Null = null) { + this.senderId = senderId; + this.timestamp = timestamp; + this.text = text; + this.key = key ?? text; + } + + public function render(forText: (String) -> T, forImage: (String, String) -> T) { + return forText(text + "\u{fe0f}"); + } +} + +@:expose +class CustomEmojiReaction extends Reaction { + public final uri: String; + + public function new(senderId: String, timestamp: String, text: String, uri: String) { + super(senderId, timestamp, text, uri); + this.uri = uri; + } + + override public function render(forText: (String) -> T, forImage: (String, String) -> T) { + final hash = Hash.fromUri(uri); + return forImage(text, hash?.toUri() ?? uri); + } +} diff --git a/snikket/ReactionUpdate.hx b/snikket/ReactionUpdate.hx index c46a95f..37eade3 100644 --- a/snikket/ReactionUpdate.hx +++ b/snikket/ReactionUpdate.hx @@ -1,7 +1,14 @@ package snikket; +import snikket.Reaction; using Lambda; +enum abstract ReactionUpdateKind(Int) { + var EmojiReactions; + var AppendReactions; + var CompleteReactions; +} + @:nullSafety(Strict) @:expose class ReactionUpdate { @@ -10,12 +17,12 @@ class ReactionUpdate { public final serverIdBy: Null; public final localId: Null; public final chatId: String; - public final timestamp: String; public final senderId: String; - public final reactions: Array; - public final append: Bool; + public final timestamp: String; + public final reactions: Array; + public final kind: ReactionUpdateKind; - public function new(updateId: String, serverId: Null, serverIdBy: Null, localId: Null, chatId: String, timestamp: String, senderId: String, reactions: Array, ?append: Bool = false) { + public function new(updateId: String, serverId: Null, serverIdBy: Null, localId: Null, chatId: String, senderId: String, timestamp: String, reactions: Array, kind: ReactionUpdateKind) { if (serverId == null && localId == null) throw "ReactionUpdate serverId and localId cannot both be null"; if (serverId != null && serverIdBy == null) throw "serverId requires serverIdBy"; this.updateId = updateId; @@ -23,48 +30,64 @@ class ReactionUpdate { this.serverIdBy = serverIdBy; this.localId = localId; this.chatId = chatId; - this.timestamp = timestamp; this.senderId = senderId; + this.timestamp = timestamp; this.reactions = reactions; - this.append = append ?? false; + this.kind = kind; } - public function getReactions(existingReactions: Null>): Array { - if (append) { + public function getReactions(existingReactions: Null>): Array { + if (kind == AppendReactions) { // TODO: make sure a new non-custom react doesn't override any customs we've added final set: Map = []; + final list = []; for (r in existingReactions ?? []) { - set[r] = true; + if (!set.exists(r.key)) list.push(r); + set[r.key] = true; } for (r in reactions) { - set[r] = true; + if (!set.exists(r.key)) list.push(r); + set[r.key] = true; + } + return list; + } else if (kind == EmojiReactions) { + // Complete set of emoji but lacks any customs added before now + final list = reactions.array(); + for (r in existingReactions ?? []) { + final custom = Util.downcast(r, CustomEmojiReaction); + if (custom != null) list.push(custom); } - return { iterator: () -> set.keys() }.array(); - } else { + return list; + } else if (kind == CompleteReactions) { return reactions; } + throw "Unknown kind of reaction update"; } @:allow(snikket) private function inlineHashReferences() { final hashes = []; for (r in reactions) { - final hash = Hash.fromUri(r); - if (hash != null) hashes.push(hash); + final custom = Util.downcast(r, CustomEmojiReaction); + if (custom != null) { + final hash = Hash.fromUri(custom.uri); + if (hash != null) hashes.push(hash); + } } return hashes; } // Note that using this version means you don't get any fallbacks! + // It also won't update any custom emoji reactions at all @:allow(snikket) private function asStanza():Stanza { - if (append) throw "Cannot make a reaction XEP stanza for an append"; + if (kind != EmojiReactions) throw "Cannot make a reaction XEP stanza for this kind"; var attrs: haxe.DynamicAccess = { type: serverId == null ? "chat" : "groupchat", id: updateId }; var stanza = new Stanza("message", attrs); stanza.tag("reactions", { xmlns: "urn:xmpp:reactions:0", id: localId ?? serverId }); for (reaction in reactions) { - stanza.textTag("reaction", reaction); + if (!Std.is(reaction, CustomEmojiReaction)) stanza.textTag("reaction", reaction.text); } stanza.up(); diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js index a751032..d2de20e 100644 --- a/snikket/persistence/browser.js +++ b/snikket/persistence/browser.js @@ -60,6 +60,36 @@ const browser = (dbname, tokenize, stemmer) => { }); } + function hydrateStringReaction(r, senderId, timestamp) { + if (r.startsWith("ni://")){ + return new snikket.CustomEmojiReaction(senderId, timestamp, "", r); + } else { + return new snikket.Reaction(senderId, timestamp, r); + } + } + + function hydrateObjectReaction(r) { + if (r.uri) { + return new snikket.CustomEmojiReaction(r.senderId, r.timestamp, r.text, r.uri); + } else { + return new snikket.Reaction(r.senderId, r.timestamp, r.text, r.key); + } + } + + function hydrateReactionsArray(reacts, sernderId, timestamp) { + if (!reacts) return reacts; + return reacts.map(r => typeof r === "string" ? hydrateStringReaction(r, senderId, timestamp) : hydrateObjectReaction(r)); + } + + function hydrateReactions(map, timestamp) { + if (!map) return new Map(); + const newMap = new Map(); + for (const [k, reacts] of map) { + newMap.set(k, reacts.map(reactOrSender => typeof reactOrSender === "string" ? hydrateStringReaction(k, reactOrSender, timestamp) : hydrateObjectReaction(reactOrSender))); + } + return newMap; + } + function hydrateMessageSync(value) { if (!value) return null; @@ -82,7 +112,7 @@ const browser = (dbname, tokenize, stemmer) => { message.replyTo = value.replyTo.map((r) => snikket.JID.parse(r)); message.threadId = value.threadId; message.attachments = value.attachments; - message.reactions = value.reactions; + message.reactions = hydrateReactions(value.reactions, message.timestamp); message.text = value.text; message.lang = value.lang; message.type = value.type || (value.isGroupchat || value.groupchat ? enums.MessageType.Channel : enums.MessageType.Chat); @@ -152,17 +182,16 @@ const browser = (dbname, tokenize, stemmer) => { } function setReactions(reactionsMap, sender, reactions) { - for (const [reaction, senders] of reactionsMap) { - if (!reactions.includes(reaction) && senders.includes(sender)) { - if (senders.length === 1) { - reactionsMap.delete(reaction); - } else { - reactionsMap.set(reaction, senders.filter((asender) => asender != sender)); - } + for (const [reaction, reacts] of reactionsMap) { + const newReacts = reacts.filter((react) => react.senderId !== sender); + if (newReacts.length < 1) { + reactionsMap.delete(reaction); + } else { + reactionsMap.set(reaction, newReacts); } } for (const reaction of reactions) { - reactionsMap.set(reaction, [...new Set([...reactionsMap.get(reaction) || [], sender])].sort()); + reactionsMap.set(reaction.key, [...reactionsMap.get(reaction.key) || [], reaction]); } return reactionsMap; } @@ -317,8 +346,8 @@ const browser = (dbname, tokenize, stemmer) => { [account, update.chatId, update.serverId || update.localId, update.senderId], [account, update.chatId, update.serverId || update.localId, update.senderId, []] ), "prev")); - const reactions = update.getReactions(lastFromSender?.value?.reactions); - await promisifyRequest(reactionStore.put({...update, reactions: reactions, append: (update.append ? update.reactions : null), messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account})); + const reactions = update.getReactions(hydrateReactionsArray(lastFromSender?.value?.reactions)); + await promisifyRequest(reactionStore.put({...update, reactions: reactions, append: (update.kind === enums.ReactionUpdateKind.AppendReactions ? update.reactions : null), messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account})); if (!result || !result.value) return null; if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return; const message = result.value; @@ -346,8 +375,14 @@ const browser = (dbname, tokenize, stemmer) => { ]).then(([result, reactionResult]) => { if (reactionResult?.value?.append && message.html().trim() == "") { this.getMessage(account, message.chatId(), reactionResult.value.serverId, reactionResult.value.localId, (reactToMessage) => { - const reactions = (reactToMessage ? Array.from(reactToMessage.reactions.keys()) : []).filter((r) => !reactionResult.value.append.includes(r)); - this.storeReaction(account, new snikket.ReactionUpdate(message.localId, reactionResult.value.serverId, reactionResult.value.serverIdBy, reactionResult.value.localId, message.chatId(), message.timestamp, message.senderId(), reactions), callback); + const previouslyAppended = hydrateReactionsArray(reactionResult.value.append, reactionResult.value.senderId, reactionResult.value.timestamp).map(r => r.key); + const reactions = []; + for (const [k, reacts] of reactToMessage.reactions) { + for (const react of reacts) { + if (react.senderId === message.senderId() && !previouslyAppended.includes(k)) reactions.push(react); + } + } + this.storeReaction(account, new snikket.ReactionUpdate(message.localId, reactionResult.value.serverId, reactionResult.value.serverIdBy, reactionResult.value.localId, message.chatId(), message.senderId(), message.timestamp, reactions, enums.ReactionUpdateKind.CompleteReactions), callback); }); return true; } else if (result?.value && !message.isIncoming() && result?.value.direction === enums.MessageDirection.MessageSent && message.versions.length < 1) { @@ -367,7 +402,7 @@ const browser = (dbname, tokenize, stemmer) => { if (event.target.result && event.target.result.value) { const time = reactionTimes.get(event.target.result.senderId); if (!time || time < event.target.result.value.timestamp) { - setReactions(reactions, event.target.result.value.senderId, event.target.result.value.reactions); + setReactions(reactions, event.target.result.value.senderId, hydrateReactionsArray(event.target.result.value.reactions, event.target.result.senderId, event.target.result.timestamp)); reactionTimes.set(event.target.result.value.senderId, event.target.result.value.timestamp); } event.target.result.continue();