git » sdk » commit 7adcfb3

Store timestamp/alt text with reactions

author Stephen Paul Weber
2024-11-14 02:44:01 UTC
committer Stephen Paul Weber
2024-11-14 02:44:01 UTC
parent 275174427e5e5c08e858deed651848474b8c3cfc

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 -0
npm/index.ts +3 -0
snikket/Chat.hx +15 -6
snikket/ChatMessage.hx +3 -3
snikket/Message.hx +10 -8
snikket/Reaction.hx +36 -0
snikket/ReactionUpdate.hx +39 -16
snikket/persistence/browser.js +49 -14

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<String, Array<String>> = [];
+	public var reactions: Map<String, Array<Reaction>> = [];
 
 	/**
 		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<String> = null) {
+		this.senderId = senderId;
+		this.timestamp = timestamp;
+		this.text = text;
+		this.key = key ?? text;
+	}
+
+	public function render<T>(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<T>(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<String>;
 	public final localId: Null<String>;
 	public final chatId: String;
-	public final timestamp: String;
 	public final senderId: String;
-	public final reactions: Array<String>;
-	public final append: Bool;
+	public final timestamp: String;
+	public final reactions: Array<Reaction>;
+	public final kind: ReactionUpdateKind;
 
-	public function new(updateId: String, serverId: Null<String>, serverIdBy: Null<String>, localId: Null<String>, chatId: String, timestamp: String, senderId: String, reactions: Array<String>, ?append: Bool = false) {
+	public function new(updateId: String, serverId: Null<String>, serverIdBy: Null<String>, localId: Null<String>, chatId: String, senderId: String, timestamp: String, reactions: Array<Reaction>, 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<String>>): Array<String> {
-		if (append) {
+	public function getReactions(existingReactions: Null<Array<Reaction>>): Array<Reaction> {
+		if (kind == AppendReactions) { // TODO: make sure a new non-custom react doesn't override any customs we've added
 			final set: Map<String, Bool> = [];
+			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<String> = { 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();