git » sdk » commit c267f59

Support custom emoji reactions incoming

author Stephen Paul Weber
2024-10-24 02:12:27 UTC
committer Stephen Paul Weber
2024-10-24 02:12:27 UTC
parent bc8a169e0680153f7953b43594b73849d6cfba4a

Support custom emoji reactions incoming

snikket/Chat.hx +2 -2
snikket/Client.hx +3 -0
snikket/Hash.hx +6 -1
snikket/Message.hx +41 -0
snikket/ReactionUpdate.hx +38 -2
snikket/persistence/browser.js +17 -9

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index 8ff867f..7df9d9f 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -719,7 +719,7 @@ class DirectChat extends Chat {
 		for (areaction => senders in m.reactions) {
 			if (areaction != reaction && senders.contains(client.accountId())) reactions.push(areaction);
 		}
-		final update = new ReactionUpdate(ID.long(), 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(), Date.format(std.Date.now()), client.accountId(), reactions);
 		persistence.storeReaction(client.accountId(), update, (stored) -> {
 			final stanza = update.asStanza();
 			for (recipient in getParticipants()) {
@@ -1136,7 +1136,7 @@ class Channel extends Chat {
 		for (areaction => senders in m.reactions) {
 			if (areaction != reaction && senders.contains(getFullJid().asString())) reactions.push(areaction);
 		}
-		final update = new ReactionUpdate(ID.long(), m.serverId, null, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions);
+		final update = new ReactionUpdate(ID.long(), m.serverId, m.chatId(), null, m.chatId(), Date.format(std.Date.now()), client.accountId(), reactions);
 		persistence.storeReaction(client.accountId(), update, (stored) -> {
 			final stanza = update.asStanza();
 			stanza.attr.set("to", chatId);
diff --git a/snikket/Client.hx b/snikket/Client.hx
index 1287b28..7726aeb 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -169,6 +169,9 @@ class Client extends EventEmitter {
 						}
 					}
 				case ReactionUpdateStanza(update):
+					for (hash in update.inlineHashReferences()) {
+						fetchMediaByHash([hash], [from]);
+					}
 					persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored));
 				default:
 					// ignore
diff --git a/snikket/Hash.hx b/snikket/Hash.hx
index 5cd4537..c0f15b7 100644
--- a/snikket/Hash.hx
+++ b/snikket/Hash.hx
@@ -52,10 +52,15 @@ class Hash {
 		if (Config.relativeHashUri) {
 			return "/.well-known/ni/" + algorithm.urlEncode() + "/" + toBase64Url();
 		} else {
-			return "ni:///" + algorithm.urlEncode() + ";" + toBase64Url();
+			return serializeUri();
 		}
 	}
 
+	@:allow(snikket)
+	private function serializeUri() {
+		return "ni:///" + algorithm.urlEncode() + ";" + toBase64Url();
+	}
+
 	public function toHex() {
 		return Bytes.ofData(hash).toHex();
 	}
diff --git a/snikket/Message.hx b/snikket/Message.hx
index 689c3cc..4882067 100644
--- a/snikket/Message.hx
+++ b/snikket/Message.hx
@@ -1,6 +1,7 @@
 package snikket;
 
 using Lambda;
+using StringTools;
 
 enum abstract MessageDirection(Int) {
 	var MessageReceived;
@@ -162,6 +163,7 @@ class Message {
 				return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
 					stanza.attr.get("id") ?? ID.long(),
 					isGroupchat ? reactionId : null,
+					isGroupchat ? msg.chatId() : null,
 					isGroupchat ? null : reactionId,
 					msg.chatId(),
 					timestamp,
@@ -214,6 +216,45 @@ class Message {
 		if (reply != null) {
 			final replyToJid = reply.attr.get("to");
 			final replyToID = reply.attr.get("id");
+
+			final text = msg.text;
+			if (text != null && EmojiUtil.isOnlyEmoji(text.trim())) {
+				return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
+					stanza.attr.get("id") ?? ID.long(),
+					isGroupchat ? replyToID : null,
+					isGroupchat ? msg.chatId() : null,
+					isGroupchat ? null : replyToID,
+					msg.chatId(),
+					timestamp,
+					msg.senderId(),
+					[text.trim()],
+					true
+				)));
+			}
+
+			if (html != null) {
+				final body = html.getChild("body", "http://www.w3.org/1999/xhtml");
+				if (body != null) {
+					final els = body.allTags();
+					if (els.length == 1 && els[0].name == "img") {
+						final hash = Hash.fromUri(els[0].attr.get("src") ?? "");
+						if (hash != null) {
+							return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate(
+								stanza.attr.get("id") ?? ID.long(),
+								isGroupchat ? replyToID : null,
+								isGroupchat ? msg.chatId() : null,
+								isGroupchat ? null : replyToID,
+								msg.chatId(),
+								timestamp,
+								msg.senderId(),
+								[hash.serializeUri()],
+								true
+							)));
+						}
+					}
+				}
+			}
+
 			if (replyToID != null) {
 				// Reply stub
 				final replyToMessage = new ChatMessage();
diff --git a/snikket/ReactionUpdate.hx b/snikket/ReactionUpdate.hx
index 832948e..c46a95f 100644
--- a/snikket/ReactionUpdate.hx
+++ b/snikket/ReactionUpdate.hx
@@ -1,28 +1,64 @@
 package snikket;
 
+using Lambda;
+
 @:nullSafety(Strict)
+@:expose
 class ReactionUpdate {
 	public final updateId: String;
 	public final serverId: Null<String>;
+	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 function new(updateId: String, serverId: Null<String>, localId: Null<String>, chatId: String, timestamp: String, senderId: String, reactions: Array<String>) {
+	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) {
 		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;
 		this.serverId = serverId;
+		this.serverIdBy = serverIdBy;
 		this.localId = localId;
 		this.chatId = chatId;
 		this.timestamp = timestamp;
 		this.senderId = senderId;
 		this.reactions = reactions;
+		this.append = append ?? false;
+	}
+
+	public function getReactions(existingReactions: Null<Array<String>>): Array<String> {
+		if (append) {
+			final set: Map<String, Bool> = [];
+			for (r in existingReactions ?? []) {
+				set[r] = true;
+			}
+			for (r in reactions) {
+				set[r] = true;
+			}
+			return { iterator: () -> set.keys() }.array();
+		} else {
+			return reactions;
+		}
+	}
+
+	@:allow(snikket)
+	private function inlineHashReferences() {
+		final hashes = [];
+		for (r in reactions) {
+			final hash = Hash.fromUri(r);
+			if (hash != null) hashes.push(hash);
+		}
+		return hashes;
 	}
 
 	// Note that using this version means you don't get any fallbacks!
-	public function asStanza():Stanza {
+	@:allow(snikket)
+	private function asStanza():Stanza {
+		if (append) throw "Cannot make a reaction XEP stanza for an append";
+
 		var attrs: haxe.DynamicAccess<String> = { type: serverId == null ? "chat" : "groupchat", id: updateId };
 		var stanza = new Stanza("message", attrs);
 
diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js
index 9bc1e7b..a696646 100644
--- a/snikket/persistence/browser.js
+++ b/snikket/persistence/browser.js
@@ -307,21 +307,20 @@ const browser = (dbname, tokenize, stemmer) => {
 				const reactionStore = tx.objectStore("reactions");
 				let result;
 				if (update.serverId) {
-					result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId], [account, update.serverId, []])));
+					result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId, update.serverIdBy], [account, update.serverId, update.serverIdBy, []])));
 				} else {
 					result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, update.localId, update.chatId])));
 				}
-				await promisifyRequest(reactionStore.put({...update, messageId: update.serverId || update.localId, timestamp: new Date(update.timestamp), account: account}));
-				if (!result || !result.value) {
-					return null;
-				}
-				const message = result.value;
 				const lastFromSender = promisifyRequest(reactionStore.index("senders").openCursor(IDBKeyRange.bound(
 					[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}));
+				if (!result || !result.value) return null;
 				if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return;
-				setReactions(message.reactions, update.senderId, update.reactions);
+				const message = result.value;
+				setReactions(message.reactions, update.senderId, reactions);
 				store.put(message);
 				return await hydrateMessage(message);
 			})().then(callback);
@@ -339,8 +338,17 @@ const browser = (dbname, tokenize, stemmer) => {
 				message.replyToMessage = replyToMessage;
 				const tx = db.transaction(["messages", "reactions"], "readwrite");
 				const store = tx.objectStore("messages");
-				return promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => {
-					if (result?.value && !message.isIncoming() && result?.value.direction === enums.MessageDirection.MessageSent && message.versions.length < 1) {
+				return Promise.all([
+					promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))),
+					promisifyRequest(tx.objectStore("reactions").openCursor(IDBKeyRange.only([account, message.chatId(), message.senderId(), message.localId])))
+				]).then(([result, reactionResult]) => {
+					if (reactionResult?.value?.append && message.html().trim() == "") {
+						this.getMessage(account, message.chatId(), reactionResult.value.serverId, reactionResult.value.localId, (reactToMessage) => {
+							const reactions = 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);
+						});
+						return true;
+					} else if (result?.value && !message.isIncoming() && result?.value.direction === enums.MessageDirection.MessageSent && message.versions.length < 1) {
 						// Duplicate, we trust our own sent ids
 						return promisifyRequest(result.delete());
 					} else if (result?.value && (result.value.sender == message.senderId() || result.value.type == enums.MessageType.MessageCall) && (message.versions.length > 0 || (result.value.versions || []).length > 0)) {