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; + 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 function new(updateId: String, serverId: Null, localId: Null, chatId: String, timestamp: String, senderId: String, reactions: Array) { + public function new(updateId: String, serverId: Null, serverIdBy: Null, localId: Null, chatId: String, timestamp: String, senderId: String, reactions: Array, ?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 { + if (append) { + final set: Map = []; + 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 = { 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)) {