| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-12-19 03:04:22 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-12-19 03:45:32 UTC |
| parent | 600064d08dde5ca22a188d14992ed592a4a1391a |
| xmpp/Chat.hx | +135 | -29 |
| xmpp/ChatMessage.hx | +76 | -151 |
| xmpp/Client.hx | +31 | -22 |
| xmpp/EmojiUtil.hx | +258 | -0 |
| xmpp/Message.hx | +196 | -0 |
| xmpp/MessageSync.hx | +11 | -9 |
| xmpp/Persistence.hx | +4 | -3 |
| xmpp/ReactionUpdate.hx | +37 | -0 |
| xmpp/persistence/browser.js | +138 | -57 |
diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx index d478041..a4d7bfa 100644 --- a/xmpp/Chat.hx +++ b/xmpp/Chat.hx @@ -241,6 +241,12 @@ abstract class Chat { return EventUnhandled; // Allow others to get this event as well }); } + + public function addReaction(m:ChatMessage, reaction:String) { + final toSend = m.reply(); + toSend.text = reaction; + sendMessage(toSend); + } } @:expose @@ -262,11 +268,20 @@ class DirectChat extends Chat { var filter:MAMQueryParams = { with: this.chatId }; if (beforeId != null) filter.page = { before: beforeId }; var sync = new MessageSync(this.client, this.stream, filter); - sync.onMessages((messages) -> { - for (message in messages.messages) { - persistence.storeMessage(client.accountId(), message); + sync.onMessages((messageList) -> { + final chatMessages = []; + for (m in messageList.messages) { + switch (m) { + case ChatMessageStanza(message): + persistence.storeMessage(client.accountId(), message, (m)->{}); + if (message.chatId() == chatId) chatMessages.push(message); + case ReactionUpdateStanza(update): + persistence.storeReaction(client.accountId(), update, (m)->{}); + default: + // ignore + } } - handler(messages.messages.filter((m) -> m.chatId() == chatId)); + handler(chatMessages); }); sync.fetchNext(); } @@ -289,12 +304,15 @@ class DirectChat extends Chat { } public function correctMessage(localId:String, message:ChatMessage) { + final toSend = message.clone(); message = prepareOutgoingMessage(message); - persistence.correctMessage(client.accountId(), localId, message, (corrected) -> { - message.versions = corrected.versions; + message.versions = [toSend]; // This is a correction + message.localId = localId; + persistence.storeMessage(client.accountId(), message, (corrected) -> { + toSend.versions = corrected.versions; for (recipient in message.recipients) { message.to = recipient; - client.sendStanza(message.asStanza()); + client.sendStanza(toSend.asStanza()); } if (localId == lastMessage?.localId) { setLastMessage(corrected); @@ -307,13 +325,47 @@ class DirectChat extends Chat { public function sendMessage(message:ChatMessage):Void { client.chatActivity(this); message = prepareOutgoingMessage(message); - persistence.storeMessage(client.accountId(), message); - for (recipient in message.recipients) { - message.to = recipient; - client.sendStanza(message.asStanza()); + final fromStanza = Message.fromStanza(message.asStanza(), client.jid); + switch (fromStanza) { + case ChatMessageStanza(_): + persistence.storeMessage(client.accountId(), message, (stored) -> { + for (recipient in message.recipients) { + message.to = recipient; + client.sendStanza(message.asStanza()); + } + setLastMessage(message); + client.trigger("chats/update", [this]); + client.notifyMessageHandlers(stored); + }); + case ReactionUpdateStanza(update): + persistence.storeReaction(client.accountId(), update, (stored) -> { + for (recipient in message.recipients) { + message.to = recipient; + client.sendStanza(message.asStanza()); + } + if (stored != null) client.notifyMessageHandlers(stored); + }); + default: + trace("Invalid message", fromStanza); + throw "Trying to send invalid message."; } - setLastMessage(message); - client.trigger("chats/update", [this]); + } + + 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); + } + final update = new ReactionUpdate(ID.long(), 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()) { + stanza.attr.set("to", recipient); + client.sendStanza(stanza); + } + if (stored != null) client.notifyMessageHandlers(stored); + }); } public function lastMessageId() { @@ -446,15 +498,24 @@ class Channel extends Chat { chatId ); sync.setNewestPageFirst(false); + final chatMessages = []; sync.onMessages((messageList) -> { - for (message in messageList.messages) { - persistence.storeMessage(client.accountId(), message); + for (m in messageList.messages) { + switch (m) { + case ChatMessageStanza(message): + persistence.storeMessage(client.accountId(), message, (m)->{}); + if (message.chatId() == chatId) chatMessages.push(message); + case ReactionUpdateStanza(update): + persistence.storeReaction(client.accountId(), update, (m)->{}); + default: + // ignore + } } if (sync.hasMore()) { sync.fetchNext(); } else { inSync = true; - final lastFromSync = messageList.messages[messageList.messages.length - 1]; + final lastFromSync = chatMessages[chatMessages.length - 1]; if (lastFromSync != null && Reflect.compare(lastFromSync.timestamp, lastMessageTimestamp()) > 0) { setLastMessage(lastFromSync); client.trigger("chats/update", [this]); @@ -540,12 +601,21 @@ class Channel extends Chat { var filter:MAMQueryParams = {}; if (beforeId != null) filter.page = { before: beforeId }; var sync = new MessageSync(this.client, this.stream, filter, chatId); - sync.onMessages((messages) -> { - for (message in messages.messages) { - message = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() })); - persistence.storeMessage(client.accountId(), message); + sync.onMessages((messageList) -> { + final chatMessages = []; + for (m in messageList.messages) { + switch (m) { + case ChatMessageStanza(message): + final chatMessage = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() })); + persistence.storeMessage(client.accountId(), chatMessage, (m)->{}); + if (message.chatId() == chatId) chatMessages.push(message); + case ReactionUpdateStanza(update): + persistence.storeReaction(client.accountId(), update, (m)->{}); + default: + // ignore + } } - handler(messages.messages.filter((m) -> m.chatId() == chatId)); + handler(chatMessages); }); sync.fetchNext(); } @@ -553,7 +623,6 @@ class Channel extends Chat { } public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) { - // TODO: mark type!=groupchat as whisper somehow message.syncPoint = inSync; message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs if (message.senderId() == getFullJid().asString()) { @@ -564,6 +633,7 @@ class Channel extends Chat { } private function prepareOutgoingMessage(message:ChatMessage) { + message.groupchat = true; message.timestamp = message.timestamp ?? Date.format(std.Date.now()); message.direction = MessageSent; message.from = client.jid; @@ -575,10 +645,13 @@ class Channel extends Chat { } public function correctMessage(localId:String, message:ChatMessage) { + final toSend = message.clone(); message = prepareOutgoingMessage(message); - persistence.correctMessage(client.accountId(), localId, message, (corrected) -> { - message.versions = corrected.versions; - client.sendStanza(message.asStanza("groupchat")); + message.versions = [toSend]; // This is a correction + message.localId = localId; + persistence.storeMessage(client.accountId(), message, (corrected) -> { + toSend.versions = corrected.versions; + client.sendStanza(toSend.asStanza()); if (localId == lastMessage?.localId) { setLastMessage(corrected); client.trigger("chats/update", [this]); @@ -590,10 +663,43 @@ class Channel extends Chat { public function sendMessage(message:ChatMessage):Void { client.chatActivity(this); message = prepareOutgoingMessage(message); - persistence.storeMessage(client.accountId(), message); - client.sendStanza(message.asStanza("groupchat")); - setLastMessage(message); - client.trigger("chats/update", [this]); + final stanza = message.asStanza(); + // Fake from as it will look on reflection for storage purposes + stanza.attr.set("from", getFullJid().asString()); + final fromStanza = Message.fromStanza(stanza, client.jid); + stanza.attr.set("from", client.jid.asString()); + switch (fromStanza) { + case ChatMessageStanza(_): + persistence.storeMessage(client.accountId(), message, (stored) -> { + client.sendStanza(stanza); + setLastMessage(stored); + client.trigger("chats/update", [this]); + client.notifyMessageHandlers(stored); + }); + case ReactionUpdateStanza(update): + persistence.storeReaction(client.accountId(), update, (stored) -> { + client.sendStanza(stanza); + if (stored != null) client.notifyMessageHandlers(stored); + }); + default: + trace("Invalid message", fromStanza); + throw "Trying to send invalid message."; + } + } + + 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); + } + final update = new ReactionUpdate(ID.long(), m.serverId, 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); + client.sendStanza(stanza); + if (stored != null) client.notifyMessageHandlers(stored); + }); } public function lastMessageId() { diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx index 0d7524f..9b6fe07 100644 --- a/xmpp/ChatMessage.hx +++ b/xmpp/ChatMessage.hx @@ -10,18 +10,8 @@ import xmpp.JID; import xmpp.Identicon; import xmpp.StringUtil; import xmpp.XEP0393; - -enum MessageDirection { - MessageReceived; - MessageSent; -} - -enum MessageStatus { - MessagePending; // Message is waiting in client for sending - MessageDeliveredToServer; // Server acknowledged receipt of the message - MessageDeliveredToDevice; //The message has been delivered to at least one client device - MessageFailedToSend; // There was an error sending this message -} +import xmpp.EmojiUtil; +import xmpp.Message; class ChatAttachment { public final name: Null<String>; @@ -55,13 +45,16 @@ class ChatMessage { public var recipients: Array<JID> = []; public var replyTo: Array<JID> = []; - public var threadId (default, null): Null<String> = null; + public var replyToMessage: Null<ChatMessage> = null; + public var threadId: Null<String> = null; - public var attachments : Array<ChatAttachment> = []; + public var attachments: Array<ChatAttachment> = []; + public var reactions: Map<String, Array<String>> = []; - public var text (default, null): Null<String> = null; - public var lang (default, null): Null<String> = null; + public var text: Null<String> = null; + public var lang: Null<String> = null; + public var groupchat: Bool = false; // Only really useful for distinguishing whispers public var direction: MessageDirection = MessageReceived; public var status: MessageStatus = MessagePending; public var versions: Array<ChatMessage> = []; @@ -70,129 +63,12 @@ class ChatMessage { public function new() { } public static function fromStanza(stanza:Stanza, localJid:JID):Null<ChatMessage> { - if (stanza.attr.get("type") == "error") return null; - - var msg = new ChatMessage(); - msg.timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? Date.format(std.Date.now()); - msg.threadId = stanza.getChildText("thread"); - msg.lang = stanza.attr.get("xml:lang"); - msg.text = stanza.getChildText("body"); - if (msg.text != null && (msg.lang == null || msg.lang == "")) { - msg.lang = stanza.getChild("body")?.attr.get("xml:lang"); - } - final from = stanza.attr.get("from"); - msg.from = from == null ? null : JID.parse(from); - msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare(); - final localJidBare = localJid.asBare(); - final domain = localJid.domain; - final to = stanza.attr.get("to"); - msg.to = to == null ? localJid : JID.parse(to); - - if (msg.from != null && msg.from.equals(localJidBare)) { - var carbon = stanza.getChild("received", "urn:xmpp:carbons:2"); - if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2"); - if (carbon != null) { - var fwd = carbon.getChild("forwarded", "urn:xmpp:forward:0"); - if(fwd != null) return fromStanza(fwd.getFirstChild(), localJid); - } - } - - final localId = stanza.attr.get("id"); - if (localId != null) msg.localId = localId; - var altServerId = null; - for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) { - final id = stanzaId.attr.get("id"); - if ((stanzaId.attr.get("by") == domain || stanzaId.attr.get("by") == localJidBare.asString()) && id != null) { - msg.serverIdBy = localJidBare.asString(); - msg.serverId = id; - break; - } - altServerId = stanzaId; - } - if (msg.serverId == null && altServerId != null && stanza.attr.get("type") != "error") { - final id = altServerId.attr.get("id"); - if (id != null) { - msg.serverId = id; - msg.serverIdBy = altServerId.attr.get("by"); - } - } - msg.direction = (msg.to == null || msg.to.asBare().equals(localJidBare)) ? MessageReceived : MessageSent; - if (msg.from != null && msg.from.asBare().equals(localJidBare)) msg.direction = MessageSent; - msg.status = msg.direction == MessageReceived ? MessageDeliveredToDevice : MessageDeliveredToServer; // Delivered to us, a device - - final recipients: Map<String, Bool> = []; - final replyTo: Map<String, Bool> = []; - if (msg.to != null) { - recipients[msg.to.asBare().asString()] = true; - } - if (msg.direction == MessageReceived && msg.from != null) { - replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true; - } else if(msg.to != null) { - replyTo[msg.to.asString()] = true; - } - - final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address"); - var anyExtendedReplyTo = false; - if (addresses != null) { - for (address in addresses.allTags("address")) { - final jid = address.attr.get("jid"); - if (address.attr.get("type") == "noreply") { - replyTo.clear(); - } else if (jid == null) { - trace("No support for addressing to non-jid", address); - return null; - } else if (address.attr.get("type") == "to" || address.attr.get("type") == "cc") { - recipients[JID.parse(jid).asBare().asString()] = true; - if (!anyExtendedReplyTo) replyTo[JID.parse(jid).asString()] = true; // reply all - } else if (address.attr.get("type") == "replyto" || address.attr.get("type") == "replyroom") { - if (!anyExtendedReplyTo) { - replyTo.clear(); - anyExtendedReplyTo = true; - } - replyTo[JID.parse(jid).asString()] = true; - } else if (address.attr.get("type") == "ofrom") { - if (JID.parse(jid).domain == msg.sender?.domain) { - // TODO: check that domain supports extended addressing - msg.sender = JID.parse(jid).asBare(); - } - } - } - } - - msg.recipients = ({ iterator: () -> recipients.keys() }).map((s) -> JID.parse(s)); - msg.recipients.sort((x, y) -> Reflect.compare(x.asString(), y.asString())); - msg.replyTo = ({ iterator: () -> replyTo.keys() }).map((s) -> JID.parse(s)); - msg.replyTo.sort((x, y) -> Reflect.compare(x.asString(), y.asString())); - - final msgFrom = msg.from; - if (msg.direction == MessageReceived && msgFrom != null && msg.replyTo.find((r) -> r.asBare().equals(msgFrom.asBare())) == null) { - trace("Don't know what chat message without from in replyTo belongs in", stanza); - return null; - } - - for (ref in stanza.allTags("reference", "urn:xmpp:reference:0")) { - if (ref.attr.get("begin") == null && ref.attr.get("end") == null) { - final sims = ref.getChild("media-sharing", "urn:xmpp:sims:1"); - if (sims != null) msg.attachSims(sims); - } - } - - for (sims in stanza.allTags("media-sharing", "urn:xmpp:sims:1")) { - msg.attachSims(sims); - } - - if (msg.text == null && msg.attachments.length < 1) return null; - - for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) { - msg.payloads.push(fallback); - } - - final unstyled = stanza.getChild("unstyled", "urn:xmpp:styling:0"); - if (unstyled != null) { - msg.payloads.push(unstyled); + switch Message.fromStanza(stanza, localJid) { + case ChatMessageStanza(message): + return message; + default: + return null; } - - return msg; } public function attachSims(sims: Stanza) { @@ -210,6 +86,14 @@ class ChatMessage { if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes)); } + public function reply() { + final m = new ChatMessage(); + m.groupchat = groupchat; + m.threadId = threadId ?? ID.long(); + m.replyToMessage = this; + return m; + } + public function set_localId(localId:String):String { if(this.localId != null) { throw new Exception("Message already has a localId set"); @@ -229,19 +113,16 @@ class ChatMessage { } public function html():String { - var body = text ?? ""; + final codepoints = StringUtil.codepointArray(text ?? ""); // TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle? - final fallback = payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:fallback:0" && (p.attr.get("for") == "jabber:x:oob" || p.attr.get("for") == "urn:xmpp:sims:1")); - if (fallback != null) { - final bodyFallback = fallback.getChild("body"); - if (bodyFallback != null) { - final codepoints = StringUtil.codepointArray(body); - final start = Std.parseInt(bodyFallback.attr.get("start") ?? "0") ?? 0; - final end = Std.parseInt(bodyFallback.attr.get("end") ?? Std.string(codepoints.length)) ?? codepoints.length; - codepoints.splice(start, (end - start)); - body = codepoints.join(""); - } + final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter( + (p) -> p.attr.get("xmlns") == "urn:xmpp:fallback:0" && (p.attr.get("for") == "jabber:x:oob" || p.attr.get("for") == "urn:xmpp:sims:1" || (replyToMessage != null && p.attr.get("for") == "urn:xmpp:reply:0")) + ).map((p) -> p.getChild("body")).map((b) -> b == null ? null : { start: Std.parseInt(b.attr.get("start") ?? "0") ?? 0, end: Std.parseInt(b.attr.get("end") ?? Std.string(codepoints.length)) ?? codepoints.length }).filter((b) -> b != null); + fallbacks.sort((x, y) -> x.start - y.start); + for (fallback in fallbacks) { + codepoints.splice(fallback.start, (fallback.end - fallback.start)); } + final body = codepoints.join(""); return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map((s) -> s.toString()).join("") : StringTools.htmlEscape(body); } @@ -269,9 +150,9 @@ class ChatMessage { return threadId == null ? null : Identicon.svg(threadId); } - public function asStanza(?type: String):Stanza { + public function asStanza():Stanza { var body = text; - var attrs: haxe.DynamicAccess<String> = { type: type ?? "chat" }; + var attrs: haxe.DynamicAccess<String> = { type: groupchat ? "groupchat" : "chat" }; if (from != null) attrs.set("from", from.asString()); if (to != null) attrs.set("to", to.asString()); if (localId != null) attrs.set("id", localId); @@ -284,7 +165,51 @@ class ChatMessage { addresses.tag("address", { type: "to", jid: recipient.asString(), delivered: "true" }).up(); } addresses.up(); + } else if (recipients.length == 1 && to == null) { + attrs.set("to", recipients[0].asString()); } + + final replyToM = replyToMessage; + if (replyToM != null) { + if (body == null) body = ""; + final lines = replyToM.text?.split("\n") ?? []; + var quoteText = ""; + for (line in lines) { + if (!~/^(?:> ?){3,}/.match(line)) { + if (line.charAt(0) == ">") { + quoteText += ">" + line; + } else { + quoteText += "> " + line; + } + } + } + final reaction = EmojiUtil.isEmoji(StringTools.trim(body)) ? StringTools.trim(body) : null; + body = quoteText + "\n" + body; + final replyId = replyToM.groupchat ? replyToM.serverId : replyToM.localId; + if (replyId != null) { + final codepoints = StringUtil.codepointArray(quoteText); + if (reaction != null) { + final addedReactions: Map<String, Bool> = []; + stanza.tag("reactions", { xmlns: "urn:xmpp:reactions:0", id: replyId }); + stanza.textTag("reaction", reaction); + addedReactions[reaction] = true; + + for (areaction => senders in replyToM.reactions) { + if (!(addedReactions[areaction] ?? false) && senders.contains(senderId())) { + addedReactions[areaction] = true; + stanza.textTag("reaction", areaction); + } + } + stanza.up(); + stanza.tag("fallback", { xmlns: "urn:xmpp:fallback:0", "for": "urn:xmpp:reactions:0" }) + .tag("body").up().up(); + } + stanza.tag("fallback", { xmlns: "urn:xmpp:fallback:0", "for": "urn:xmpp:reply:0" }) + .tag("body", { start: "0", end: Std.string(codepoints.length + 1) }).up().up(); + stanza.tag("reply", { xmlns: "urn:xmpp:reply:0", to: replyToM.from?.asString(), id: replyId }).up(); + } + } + for (attachment in attachments) { stanza .tag("reference", { xmlns: "urn:xmpp:reference:0", type: "data" }) diff --git a/xmpp/Client.hx b/xmpp/Client.hx index 1c0cf16..f30db57 100644 --- a/xmpp/Client.hx +++ b/xmpp/Client.hx @@ -139,28 +139,30 @@ class Client extends xmpp.EventEmitter { } } - var chatMessage = ChatMessage.fromStanza(stanza, this.jid); - if (chatMessage != null) { - var chat = getChat(chatMessage.chatId()); - if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId()); - if (chat != null) { - final updateChat = (chatMessage) -> { - if (chatMessage.versions.length < 1 || chat.lastMessageId() == chatMessage.serverId || chat.lastMessageId() == chatMessage.localId) { - chat.setLastMessage(chatMessage); - if (chatMessage.versions.length < 1) chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0); - chatActivity(chat); + switch (Message.fromStanza(stanza, this.jid)) { + case ChatMessageStanza(chatMessage): + var chat = getChat(chatMessage.chatId()); + if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId()); + if (chat != null) { + final updateChat = (chatMessage) -> { + if (chatMessage.versions.length < 1 || chat.lastMessageId() == chatMessage.serverId || chat.lastMessageId() == chatMessage.localId) { + chat.setLastMessage(chatMessage); + if (chatMessage.versions.length < 1) chat.setUnreadCount(chatMessage.isIncoming() ? chat.unreadCount() + 1 : 0); + chatActivity(chat); + } + notifyMessageHandlers(chatMessage); + }; + chatMessage = chat.prepareIncomingMessage(chatMessage, stanza); + if (chatMessage.serverId == null) { + updateChat(chatMessage); + } else { + persistence.storeMessage(accountId(), chatMessage, updateChat); } - notifyMessageHandlers(chatMessage); - }; - chatMessage = chat.prepareIncomingMessage(chatMessage, stanza); - final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0"); - if (replace == null || replace.attr.get("id") == null) { - if (chatMessage.serverId != null) persistence.storeMessage(accountId(), chatMessage); - updateChat(chatMessage); - } else { - persistence.correctMessage(accountId(), replace.attr.get("id"), chatMessage, updateChat); } - } + case ReactionUpdateStanza(update): + persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored)); + default: + // ignore } final pubsubEvent = PubsubEvent.fromStanza(stanza); @@ -820,8 +822,15 @@ class Client extends xmpp.EventEmitter { ); sync.setNewestPageFirst(false); sync.onMessages((messageList) -> { - for (message in messageList.messages) { - persistence.storeMessage(accountId(), message); + for (m in messageList.messages) { + switch (m) { + case ChatMessageStanza(message): + persistence.storeMessage(accountId(), message, (m)->{}); + case ReactionUpdateStanza(update): + persistence.storeReaction(accountId(), update, (m)->{}); + default: + // ignore + } } if (sync.hasMore()) { sync.fetchNext(); diff --git a/xmpp/EmojiUtil.hx b/xmpp/EmojiUtil.hx new file mode 100644 index 0000000..9a61e53 --- /dev/null +++ b/xmpp/EmojiUtil.hx @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2017, Daniel Gultsch All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation and/or + * other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package xmpp; + +class EmojiUtil { + + public static final MISC_SYMBOLS_AND_PICTOGRAPHS = new UnicodeRange(0x1F300, 0x1F5FF); + public static final SUPPLEMENTAL_SYMBOLS = new UnicodeRange(0x1F900, 0x1F9FF); + public static final EMOTICONS = new UnicodeRange(0x1F600, 0x1FAF6); + //public static final UnicodeRange TRANSPORT_SYMBOLS = new UnicodeRange(0x1F680, 0x1F6FF); + public static final MISC_SYMBOLS = new UnicodeRange(0x2600, 0x26FF); + public static final DINGBATS = new UnicodeRange(0x2700, 0x27BF); + public static final ENCLOSED_ALPHANUMERIC_SUPPLEMENT = new UnicodeRange(0x1F100, 0x1F1FF); + public static final ENCLOSED_IDEOGRAPHIC_SUPPLEMENT = new UnicodeRange(0x1F200, 0x1F2FF); + public static final REGIONAL_INDICATORS = new UnicodeRange(0x1F1E6, 0x1F1FF); + public static final GEOMETRIC_SHAPES = new UnicodeRange(0x25A0, 0x25FF); + public static final LATIN_SUPPLEMENT = new UnicodeRange(0x80, 0xFF); + public static final MISC_TECHNICAL = new UnicodeRange(0x2300, 0x23FF); + public static final TAGS = new UnicodeRange(0xE0020, 0xE007F); + public static final CYK_SYMBOLS_AND_PUNCTUATION = new UnicodeList(0x3030, 0x303D); + public static final LETTERLIKE_SYMBOLS = new UnicodeList(0x2122, 0x2139); + + public static final KEYCAP_COMBINEABLE = new UnicodeBlocks(new UnicodeList(0x23), new UnicodeList(0x2A), new UnicodeRange(0x30, 0x39)); + + public static final SYMBOLIZE = new UnicodeBlocks( + GEOMETRIC_SHAPES, + LATIN_SUPPLEMENT, + CYK_SYMBOLS_AND_PUNCTUATION, + LETTERLIKE_SYMBOLS, + KEYCAP_COMBINEABLE); + public static final EMOJIS = new UnicodeBlocks( + MISC_SYMBOLS_AND_PICTOGRAPHS, + SUPPLEMENTAL_SYMBOLS, + EMOTICONS, + //TRANSPORT_SYMBOLS, + MISC_SYMBOLS, + DINGBATS, + ENCLOSED_ALPHANUMERIC_SUPPLEMENT, + ENCLOSED_IDEOGRAPHIC_SUPPLEMENT, + MISC_TECHNICAL); + + public static final MAX_EMOIJS = 42; + + public static final ZWJ = 0x200D; + public static final VARIATION_16 = 0xFE0F; + public static final COMBINING_ENCLOSING_KEYCAP = 0x20E3; + public static final BLACK_FLAG = 0x1F3F4; + public static final FITZPATRICK = new UnicodeRange(0x1F3FB, 0x1F3FF); + + private static function parse(str: String) { + final symbols = []; + var builder = new Builder(); + var needsFinalBuild = false; + final input = StringUtil.rawCodepointArray(str); + for (i in 0...input.length) { + final cp = input[i]; + if (builder.offer(cp)) { + needsFinalBuild = true; + } else { + symbols.push(builder.build()); + builder = new Builder(); + if (builder.offer(cp)) { + needsFinalBuild = true; + } + } + } + if (needsFinalBuild) { + symbols.push(builder.build()); + } + return symbols; + } + + public static function isEmoji(input: String) { + final symbols = parse(input); + return symbols.length == 1 && symbols[0].isEmoji(); + } + + public static function isOnlyEmoji(input: String) { + final symbols = parse(input); + for (symbol in symbols) { + if (!symbol.isEmoji()) { + return false; + } + } + return symbols.length > 0; + } +} + +abstract class Symbol { + private final value: String; + + public function new(codepoints: Array<Int>) { + final builder = new StringBuf(); + for (codepoint in codepoints) { + builder.addChar(codepoint); + } + this.value = builder.toString(); + } + + public abstract function isEmoji():Bool; + + public function toString() { + return value; + } +} + +class Emoji extends Symbol { + public function new(codepoints: Array<Int>) { + super(codepoints); + } + + public function isEmoji() { + return true; + } +} + +class Other extends Symbol { + public function new(codepoints: Array<Int>) { + super(codepoints); + } + + public function isEmoji() { + return false; + } +} + +class Builder { + private final codepoints = []; + + public function new() {} + + public function offer(codepoint: Int) { + var add = false; + if (this.codepoints.length == 0) { + if (EmojiUtil.SYMBOLIZE.contains(codepoint)) { + add = true; + } else if (EmojiUtil.REGIONAL_INDICATORS.contains(codepoint)) { + add = true; + } else if (EmojiUtil.EMOJIS.contains(codepoint) && !EmojiUtil.FITZPATRICK.contains(codepoint) && codepoint != EmojiUtil.ZWJ) { + add = true; + } + } else { + var previous = codepoints[codepoints.length - 1]; + if (codepoints[0] == EmojiUtil.BLACK_FLAG) { + add = EmojiUtil.TAGS.contains(codepoint); + } else if (EmojiUtil.COMBINING_ENCLOSING_KEYCAP == codepoint) { + add = EmojiUtil.KEYCAP_COMBINEABLE.contains(previous) || previous == EmojiUtil.VARIATION_16; + } else if (EmojiUtil.SYMBOLIZE.contains(previous)) { + add = codepoint == EmojiUtil.VARIATION_16; + } else if (EmojiUtil.REGIONAL_INDICATORS.contains(previous) && EmojiUtil.REGIONAL_INDICATORS.contains(codepoint)) { + add = codepoints.length == 1; + } else if (previous == EmojiUtil.VARIATION_16) { + add = isMerger(codepoint) || codepoint == EmojiUtil.VARIATION_16; + } else if (EmojiUtil.FITZPATRICK.contains(previous)) { + add = codepoint == EmojiUtil.ZWJ; + } else if (EmojiUtil.ZWJ == previous) { + add = EmojiUtil.EMOJIS.contains(codepoint); + } else if (isMerger(codepoint)) { + add = true; + } else if (codepoint == EmojiUtil.VARIATION_16 && EmojiUtil.EMOJIS.contains(previous)) { + add = true; + } + } + if (add) { + codepoints.push(codepoint); + return true; + } else { + return false; + } + } + + private static function isMerger(codepoint: Int) { + return codepoint == EmojiUtil.ZWJ || EmojiUtil.FITZPATRICK.contains(codepoint); + } + + public function build(): Symbol { + if (codepoints.length > 0 && EmojiUtil.SYMBOLIZE.contains(codepoints[codepoints.length - 1])) { + return new Other(codepoints); + } else if (codepoints.length > 1 && EmojiUtil.KEYCAP_COMBINEABLE.contains(codepoints[0]) && codepoints[codepoints.length - 1] != EmojiUtil.COMBINING_ENCLOSING_KEYCAP) { + return new Other(codepoints); + } + return codepoints.length == 0 ? new Other(codepoints) : new Emoji(codepoints); + } +} + +class UnicodeBlocks implements UnicodeSet { + final unicodeSets: Array<UnicodeSet>; + + public function new(...sets: UnicodeSet) { + this.unicodeSets = sets; + } + + public function contains(codepoint: Int) { + for (unicodeSet in unicodeSets) { + if (unicodeSet.contains(codepoint)) { + return true; + } + } + return false; + } +} + +interface UnicodeSet { + public function contains(codepoint: Int):Bool; +} + +class UnicodeList implements UnicodeSet { + final list: Array<Int>; + + public function new(...codes: Int) { + this.list = codes; + } + + public function contains(codepoint: Int) { + return this.list.contains(codepoint); + } +} + +class UnicodeRange implements UnicodeSet { + private final lower: Int; + private final upper: Int; + + public function new(lower: Int, upper: Int) { + this.lower = lower; + this.upper = upper; + } + + public function contains(codePoint: Int) { + return codePoint >= lower && codePoint <= upper; + } +} diff --git a/xmpp/Message.hx b/xmpp/Message.hx new file mode 100644 index 0000000..b0ce876 --- /dev/null +++ b/xmpp/Message.hx @@ -0,0 +1,196 @@ +package xmpp; + +using Lambda; + +enum MessageDirection { + MessageReceived; + MessageSent; +} + +enum MessageStatus { + MessagePending; // Message is waiting in client for sending + MessageDeliveredToServer; // Server acknowledged receipt of the message + MessageDeliveredToDevice; //The message has been delivered to at least one client device + MessageFailedToSend; // There was an error sending this message +} + +enum MessageStanza { + ErrorMessageStanza(stanza: Stanza); + ChatMessageStanza(message: ChatMessage); + ReactionUpdateStanza(update: ReactionUpdate); + UnknownMessageStanza(stanza: Stanza); +} + +@:nullSafety(Strict) +class Message { + public static function fromStanza(stanza:Stanza, localJid:JID, ?inputTimestamp: String):MessageStanza { + if (stanza.attr.get("type") == "error") return ErrorMessageStanza(stanza); + + var msg = new ChatMessage(); + final timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? inputTimestamp ?? Date.format(std.Date.now()); + msg.timestamp = timestamp; + msg.threadId = stanza.getChildText("thread"); + msg.lang = stanza.attr.get("xml:lang"); + msg.text = stanza.getChildText("body"); + if (msg.text != null && (msg.lang == null || msg.lang == "")) { + msg.lang = stanza.getChild("body")?.attr.get("xml:lang"); + } + final from = stanza.attr.get("from"); + msg.from = from == null ? null : JID.parse(from); + msg.groupchat = stanza.attr.get("type") == "groupchat"; + msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare(); + final localJidBare = localJid.asBare(); + final domain = localJid.domain; + final to = stanza.attr.get("to"); + msg.to = to == null ? localJid : JID.parse(to); + + if (msg.from != null && msg.from.equals(localJidBare)) { + var carbon = stanza.getChild("received", "urn:xmpp:carbons:2"); + if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2"); + if (carbon != null) { + var fwd = carbon.getChild("forwarded", "urn:xmpp:forward:0"); + if(fwd != null) return fromStanza(fwd.getFirstChild(), localJid); + } + } + + final localId = stanza.attr.get("id"); + if (localId != null) msg.localId = localId; + var altServerId = null; + for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) { + final id = stanzaId.attr.get("id"); + if ((stanzaId.attr.get("by") == domain || stanzaId.attr.get("by") == localJidBare.asString()) && id != null) { + msg.serverIdBy = localJidBare.asString(); + msg.serverId = id; + break; + } + altServerId = stanzaId; + } + if (msg.serverId == null && altServerId != null && stanza.attr.get("type") != "error") { + final id = altServerId.attr.get("id"); + if (id != null) { + msg.serverId = id; + msg.serverIdBy = altServerId.attr.get("by"); + } + } + msg.direction = (msg.to == null || msg.to.asBare().equals(localJidBare)) ? MessageReceived : MessageSent; + if (msg.from != null && msg.from.asBare().equals(localJidBare)) msg.direction = MessageSent; + msg.status = msg.direction == MessageReceived ? MessageDeliveredToDevice : MessageDeliveredToServer; // Delivered to us, a device + + final recipients: Map<String, Bool> = []; + final replyTo: Map<String, Bool> = []; + if (msg.to != null) { + recipients[msg.to.asBare().asString()] = true; + } + if (msg.direction == MessageReceived && msg.from != null) { + replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true; + } else if(msg.to != null) { + replyTo[msg.to.asString()] = true; + } + + final addresses = stanza.getChild("addresses", "http://jabber.org/protocol/address"); + var anyExtendedReplyTo = false; + if (addresses != null) { + for (address in addresses.allTags("address")) { + final jid = address.attr.get("jid"); + if (address.attr.get("type") == "noreply") { + replyTo.clear(); + } else if (jid == null) { + trace("No support for addressing to non-jid", address); + return UnknownMessageStanza(stanza); + } else if (address.attr.get("type") == "to" || address.attr.get("type") == "cc") { + recipients[JID.parse(jid).asBare().asString()] = true; + if (!anyExtendedReplyTo) replyTo[JID.parse(jid).asString()] = true; // reply all + } else if (address.attr.get("type") == "replyto" || address.attr.get("type") == "replyroom") { + if (!anyExtendedReplyTo) { + replyTo.clear(); + anyExtendedReplyTo = true; + } + replyTo[JID.parse(jid).asString()] = true; + } else if (address.attr.get("type") == "ofrom") { + if (JID.parse(jid).domain == msg.sender?.domain) { + // TODO: check that domain supports extended addressing + msg.sender = JID.parse(jid).asBare(); + } + } + } + } + + msg.recipients = ({ iterator: () -> recipients.keys() }).map((s) -> JID.parse(s)); + msg.recipients.sort((x, y) -> Reflect.compare(x.asString(), y.asString())); + msg.replyTo = ({ iterator: () -> replyTo.keys() }).map((s) -> JID.parse(s)); + msg.replyTo.sort((x, y) -> Reflect.compare(x.asString(), y.asString())); + + final msgFrom = msg.from; + if (msg.direction == MessageReceived && msgFrom != null && msg.replyTo.find((r) -> r.asBare().equals(msgFrom.asBare())) == null) { + trace("Don't know what chat message without from in replyTo belongs in", stanza); + return UnknownMessageStanza(stanza); + } + + final reactionsEl = stanza.getChild("reactions", "urn:xmpp:reactions:0"); + if (reactionsEl != null) { + // A reaction update is never also a chat message + final reactions = reactionsEl.allTags("reaction").map((r) -> r.getText()); + final reactionId = reactionsEl.attr.get("id"); + if (reactionId != null) { + return ReactionUpdateStanza(new ReactionUpdate( + stanza.attr.get("id") ?? ID.long(), + stanza.attr.get("type") == "groupchat" ? reactionId : null, + stanza.attr.get("type") != "groupchat" ? reactionId : null, + msg.chatId(), + timestamp, + msg.senderId(), + reactions + )); + } + } + + for (ref in stanza.allTags("reference", "urn:xmpp:reference:0")) { + if (ref.attr.get("begin") == null && ref.attr.get("end") == null) { + final sims = ref.getChild("media-sharing", "urn:xmpp:sims:1"); + if (sims != null) msg.attachSims(sims); + } + } + + for (sims in stanza.allTags("media-sharing", "urn:xmpp:sims:1")) { + msg.attachSims(sims); + } + + if (msg.text == null && msg.attachments.length < 1) return UnknownMessageStanza(stanza); + + for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) { + msg.payloads.push(fallback); + } + + final unstyled = stanza.getChild("unstyled", "urn:xmpp:styling:0"); + if (unstyled != null) { + msg.payloads.push(unstyled); + } + + final reply = stanza.getChild("reply", "urn:xmpp:reply:0"); + if (reply != null) { + final replyToJid = reply.attr.get("to"); + final replyToID = reply.attr.get("id"); + if (replyToID != null) { + // Reply stub + final replyToMessage = new ChatMessage(); + replyToMessage.groupchat = msg.groupchat; + replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid); + if (msg.groupchat) { + replyToMessage.serverId = replyToID; + } else { + replyToMessage.localId = replyToID; + } + msg.replyToMessage = replyToMessage; + } + } + + final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0"); + final replaceId = replace?.attr?.get("id"); + if (replaceId != null) { + msg.versions = [msg.clone()]; + Reflect.setField(msg, "localId", replaceId); + } + + return ChatMessageStanza(msg); + } +} diff --git a/xmpp/MessageSync.hx b/xmpp/MessageSync.hx index ec39db3..a32e14e 100644 --- a/xmpp/MessageSync.hx +++ b/xmpp/MessageSync.hx @@ -3,14 +3,14 @@ package xmpp; import haxe.Exception; import xmpp.Client; -import xmpp.ChatMessage; +import xmpp.Message; import xmpp.GenericStream; import xmpp.ResultSet; import xmpp.queries.MAMQuery; typedef MessageList = { var sync : MessageSync; - var messages : Array<ChatMessage>; + var messages : Array<MessageStanza>; } typedef MessageListHandler = (MessageList)->Void; @@ -44,7 +44,7 @@ class MessageSync { if (complete) { throw new Exception("Attempt to fetch messages, but already complete"); } - final messages:Array<ChatMessage> = []; + final messages:Array<MessageStanza> = []; if (lastPage == null) { if (newestPageFirst == true && (filter.page == null || filter.page.before == null)) { if (filter.page == null) filter.page = {}; @@ -80,13 +80,15 @@ class MessageSync { jmi.set(jmiChildren[0].attr.get("id"), originalMessage); } - var msg = ChatMessage.fromStanza(originalMessage, client.jid); - if (msg == null) return EventHandled; + var msg = Message.fromStanza(originalMessage, client.jid, timestamp); - msg.serverId = result.attr.get("id"); - msg.serverIdBy = serviceJID; - msg.syncPoint = true; - msg.timestamp = timestamp; + switch (msg) { + case ChatMessageStanza(chatMessage): + chatMessage.serverId = result.attr.get("id"); + chatMessage.serverIdBy = serviceJID; + chatMessage.syncPoint = true; + default: + } messages.push(msg); diff --git a/xmpp/Persistence.hx b/xmpp/Persistence.hx index 4dbc56f..116c8ba 100644 --- a/xmpp/Persistence.hx +++ b/xmpp/Persistence.hx @@ -1,17 +1,18 @@ package xmpp; import haxe.io.BytesData; -import xmpp.ChatMessage; import xmpp.Chat; +import xmpp.ChatMessage; +import xmpp.Message; abstract class Persistence { abstract public function lastId(accountId: String, chatId: Null<String>, callback:(serverId:Null<String>)->Void):Void; abstract public function storeChat(accountId: String, chat: Chat):Void; abstract public function getChats(accountId: String, callback: (chats:Array<SerializedChat>)->Void):Void; abstract public function getChatsUnreadDetails(accountId: String, chats: Array<Chat>, callback: (details:Array<{ chatId: String, message: ChatMessage, unreadCount: Int }>)->Void):Void; - abstract public function storeMessage(accountId: String, message: ChatMessage):Void; + abstract public function storeReaction(accountId: String, update: ReactionUpdate, callback: (Null<ChatMessage>)->Void):Void; + abstract public function storeMessage(accountId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void; abstract public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, callback: (ChatMessage)->Void):Void; - abstract public function correctMessage(accountId: String, localId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void; abstract public function getMessages(accountId: String, chatId: String, beforeId: Null<String>, beforeTime: Null<String>, callback: (messages:Array<ChatMessage>)->Void):Void; abstract public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (uri:Null<String>)->Void):Void; abstract public function storeMedia(mime:String, bytes:BytesData, callback: ()->Void):Void; diff --git a/xmpp/ReactionUpdate.hx b/xmpp/ReactionUpdate.hx new file mode 100644 index 0000000..908ee49 --- /dev/null +++ b/xmpp/ReactionUpdate.hx @@ -0,0 +1,37 @@ +package xmpp; + +@:nullSafety(Strict) +class ReactionUpdate { + public final updateId: String; + public final serverId: 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 function new(updateId: String, serverId: Null<String>, localId: Null<String>, chatId: String, timestamp: String, senderId: String, reactions: Array<String>) { + if (serverId == null && localId == null) throw "ReactionUpdate serverId and localId cannot both be null"; + this.updateId = updateId; + this.serverId = serverId; + this.localId = localId; + this.chatId = chatId; + this.timestamp = timestamp; + this.senderId = senderId; + this.reactions = reactions; + } + + // Note that using this version means you don't get any fallbacks! + public function asStanza():Stanza { + 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); + } + stanza.up(); + + return stanza; + } +} diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js index fbf030c..f635992 100644 --- a/xmpp/persistence/browser.js +++ b/xmpp/persistence/browser.js @@ -10,18 +10,11 @@ exports.xmpp.persistence = { dbOpenReq.onupgradeneeded = (event) => { const upgradeDb = event.target.result; if (!db.objectStoreNames.contains("messages")) { - const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverIdBy", "serverId", "localId"] }); + const messages = upgradeDb.createObjectStore("messages", { keyPath: ["account", "serverId", "serverIdBy", "localId"] }); messages.createIndex("chats", ["account", "chatId", "timestamp"]); messages.createIndex("localId", ["account", "localId", "chatId"]); - } - const messages = event.target.transaction.objectStore("messages"); - if (!messages.indexNames.contains("accounts")) { messages.createIndex("accounts", ["account", "timestamp"]); } - if (messages.index("localId").keyPath.toString() !== "account,localId,chatId") { - messages.deleteIndex("localId"); - messages.createIndex("localId", ["account", "localId", "chatId"]); - } if (!db.objectStoreNames.contains("keyvaluepairs")) { upgradeDb.createObjectStore("keyvaluepairs"); } @@ -31,21 +24,14 @@ exports.xmpp.persistence = { if (!db.objectStoreNames.contains("services")) { upgradeDb.createObjectStore("services", { keyPath: ["account", "serviceId"] }); } + if (!db.objectStoreNames.contains("reactions")) { + const reactions = upgradeDb.createObjectStore("reactions", { keyPath: ["account", "chatId", "senderId", "updateId"] }); + reactions.createIndex("senders", ["account", "chatId", "messageId", "senderId", "timestamp"]); + } }; dbOpenReq.onsuccess = (event) => { db = event.target.result; - if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services")) { - db.close(); - openDb(db.version + 1); - return; - } - const tx = db.transaction(["messages"], "readonly"); - if (!tx.objectStore("messages").indexNames.contains("accounts")) { - db.close(); - openDb(db.version + 1); - return; - } - if (tx.objectStore("messages").index("localId").keyPath.toString() !== "account,localId,chatId") { + if (!db.objectStoreNames.contains("messages") || !db.objectStoreNames.contains("keyvaluepairs") || !db.objectStoreNames.contains("chats") || !db.objectStoreNames.contains("services") || !db.objectStoreNames.contains("reactions")) { db.close(); openDb(db.version + 1); return; @@ -69,7 +55,13 @@ exports.xmpp.persistence = { }); } - function hydrateMessage(value) { + async function hydrateMessage(value) { + if (!value) return null; + + const tx = db.transaction(["messages"], "readonly"); + const store = tx.objectStore("messages"); + let replyToMessage = value.replyToMessage && await hydrateMessage((await promisifyRequest(store.openCursor(IDBKeyRange.only(value.replyToMessage))))?.value); + const message = new xmpp.ChatMessage(); message.localId = value.localId ? value.localId : null; message.serverId = value.serverId ? value.serverId : null; @@ -81,10 +73,13 @@ exports.xmpp.persistence = { message.sender = value.sender && xmpp.JID.parse(value.sender); message.recipients = value.recipients.map((r) => xmpp.JID.parse(r)); message.replyTo = value.replyTo.map((r) => xmpp.JID.parse(r)); + message.replyToMessage = replyToMessage; message.threadId = value.threadId; message.attachments = value.attachments; + message.reactions = value.reactions; message.text = value.text; message.lang = value.lang; + message.groupchat = value.groupchat; message.direction = value.direction == "MessageReceived" ? xmpp.MessageDirection.MessageReceived : xmpp.MessageDirection.MessageSent; switch (value.status) { case "MessagePending": @@ -102,7 +97,7 @@ exports.xmpp.persistence = { default: message.status = message.serverId ? xmpp.MessageStatus.MessageDeliveredToServer : xmpp.MessageStatus.MessagePending; } - message.versions = (value.versions || []).map(hydrateMessage); + message.versions = await Promise.all((value.versions || []).map(hydrateMessage)); message.payloads = (value.payloads || []).map(xmpp.Stanza.parse); return message; } @@ -122,6 +117,7 @@ exports.xmpp.persistence = { recipients: message.recipients.map((r) => r.asString()), replyTo: message.replyTo.map((r) => r.asString()), timestamp: new Date(message.timestamp), + replyToMessage: message.replyToMessage && [account, message.replyToMessage.serverId || "", message.replyToMessage.serverIdBy || "", message.replyToMessage.localId || ""], direction: message.direction.toString(), status: message.status.toString(), versions: message.versions.map((m) => serializeMessage(account, m)), @@ -129,6 +125,40 @@ exports.xmpp.persistence = { } } + function correctMessage(account, message, result) { + // Newest (by timestamp) version wins for head + const newVersions = message.versions.length < 1 ? [message] : message.versions; + const storedVersions = result.value.versions || []; + // TODO: dedupe? There shouldn't be dupes... + const versions = (storedVersions.length < 1 ? [result.value] : storedVersions).concat(newVersions.map((nv) => serializeMessage(account, nv))).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + const head = {...versions[0]}; + // Can't change primary key + head.serverIdBy = result.value.serverIdBy; + head.serverId = result.value.serverId; + head.localId = result.value.localId; + head.timestamp = result.value.timestamp; // Edited version is not newer + head.versions = versions; + head.reactions = result.value.reactions; // Preserve these, edit doesn't touch them + result.update(head); + return head; + } + + 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 of reactions) { + reactionsMap.set(reaction, [...new Set([...reactionsMap.get(reaction) || [], sender])].sort()); + } + return reactionsMap; + } + return { lastId: function(account, jid, callback) { const tx = db.transaction(["messages"], "readonly"); @@ -220,17 +250,17 @@ exports.xmpp.persistence = { if (readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent") { result[value.chatId].foundAll = true; } else { - result[value.chatId].unreadCount++; + result[value.chatId] = result[value.chatId].then((details) => { details.unreadCount++; return details; }); } } } else { const readUpTo = chats[value.chatId]?.readUpTo(); const haveRead = readUpTo === value.serverId || readUpTo === value.localId || value.direction == "MessageSent"; - result[value.chatId] = { chatId: value.chatId, message: hydrateMessage(value), unreadCount: haveRead ? 0 : 1, foundAll: haveRead }; + result[value.chatId] = hydrateMessage(value).then((m) => ({ chatId: value.chatId, message: m, unreadCount: haveRead ? 0 : 1, foundAll: haveRead })); } event.target.result.continue(); } else { - callback(Object.values(result)); + Promise.all(Object.values(result)).then(callback); } } cursor.onerror = (event) => { @@ -239,20 +269,92 @@ exports.xmpp.persistence = { } }, - storeMessage: function(account, message) { - const tx = db.transaction(["messages"], "readwrite"); + getMessage: function(account, chatId, serverId, localId, callback) { + const tx = db.transaction(["messages"], "readonly"); const store = tx.objectStore("messages"); + (async function() { + let result; + if (serverId) { + result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, serverId], [account, serverId, []]))); + } else { + result = await promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, chatId]))); + } + if (!result || !result.value) return null; + const message = result.value; + return await hydrateMessage(message); + })().then(callback); + }, + + storeReaction: function(account, update, callback) { + (async function() { + const tx = db.transaction(["messages", "reactions"], "readwrite"); + const store = tx.objectStore("messages"); + const reactionStore = tx.objectStore("reactions"); + let result; + if (update.serverId) { + result = await promisifyRequest(store.openCursor(IDBKeyRange.bound([account, update.serverId], [account, update.serverId, []]))); + } 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")); + if (lastFromSender?.value && lastFromSender.value.timestamp > new Date(update.timestamp)) return; + setReactions(message.reactions, update.senderId, update.reactions); + store.put(message); + return await hydrateMessage(message); + })().then(callback); + }, + + storeMessage: function(account, message, callback) { if (!message.chatId()) throw "Cannot store a message with no chatId"; if (!message.serverId && !message.localId) throw "Cannot store a message with no id"; if (!message.serverId && message.isIncoming()) throw "Cannot store an incoming message with no server id"; if (message.serverId && !message.serverIdBy) throw "Cannot store a message with a server id and no by"; - promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, message.localId || [], message.chatId()]))).then((result) => { - if (result?.value && !message.isIncoming() && result?.value.direction === "MessageSent") { - // Duplicate, we trust our own sent ids - return promisifyRequest(result.delete()); - } - }).then(() => { - store.put(serializeMessage(account, message)); + new Promise((resolve) => + // Hydrate reply stubs + message.replyToMessage && !message.replyToMessage.serverIdBy ? this.getMessage(account, message.chatId(), message.replyToMessage?.serverId, message.replyToMessage?.localId, resolve) : resolve(message.replyToMessage) + ).then((replyToMessage) => { + 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 === "MessageSent") { + // Duplicate, we trust our own sent ids + return promisifyRequest(result.delete()); + } else if (result?.value && result.value.sender == message.senderId() && (message.versions.length > 0 || (result.value.versions || []).length > 0)) { + hydrateMessage(correctMessage(account, message, result)).then(callback); + return true; + } + }).then((done) => { + if (!done) { + // There may be reactions already if we are paging backwards + const cursor = tx.objectStore("reactions").index("senders").openCursor(IDBKeyRange.bound([account, message.chatId(), (message.groupchat ? message.serverId : message.localId) || ""], [account, message.chatId(), (message.groupchat ? message.serverId : message.localId) || "", []]), "prev"); + const reactions = new Map(); + const reactionTimes = new Map(); + cursor.onsuccess = (event) => { + 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); + reactionTimes.set(event.target.result.value.senderId, event.target.result.value.timestamp); + } + event.target.result.continue(); + } else { + message.reactions = reactions; + store.put(serializeMessage(account, message)); + callback(message); + } + }; + cursor.onerror = console.error; + } + }); }); }, @@ -263,28 +365,7 @@ exports.xmpp.persistence = { if (result?.value && result.value.direction === "MessageSent" && result.value.status !== "MessageDeliveredToDevice") { const newStatus = { ...result.value, status: status.toString() }; result.update(newStatus); - callback(hydrateMessage(newStatus)); - } - }); - }, - - correctMessage: function(account, localId, message, callback) { - const tx = db.transaction(["messages"], "readwrite"); - const store = tx.objectStore("messages"); - promisifyRequest(store.index("localId").openCursor(IDBKeyRange.only([account, localId, message.chatId()]))).then((result) => { - if (result?.value && result.value.sender == message.senderId()) { - // NOTE: this strategy loses the ids and timestamp of the replacement messages - const withAnnotation = serializeMessage(account, message); - withAnnotation.serverIdBy = result.value.serverIdBy; - withAnnotation.serverId = result.value.serverId; - withAnnotation.localId = result.value.localId; - withAnnotation.timestamp = result.value.timestamp; // Edited version is not newer - withAnnotation.versions = [{ ...result.value, versions: [] }].concat(result.value.versions || []) - result.update(withAnnotation); - callback(hydrateMessage(withAnnotation)); - } else { - this.storeMessage(account, message); - callback(message); + hydrateMessage(newStatus).then(callback); } }); }, @@ -309,7 +390,7 @@ exports.xmpp.persistence = { result.unshift(hydrateMessage(value)); event.target.result.continue(); } else { - callback(result); + Promise.all(result).then(callback); } } cursor.onerror = (event) => {