| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-03-18 18:54:36 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-03-18 18:56:13 UTC |
| parent | c64773334b5029f842888df3b827ea5174162cf0 |
| npm/index.ts | +1 | -0 |
| snikket/Chat.hx | +49 | -34 |
| snikket/ChatMessage.hx | +110 | -157 |
| snikket/ChatMessageBuilder.hx | +333 | -0 |
| snikket/Client.hx | +12 | -6 |
| snikket/Message.hx | +34 | -29 |
| snikket/MessageSync.hx | +11 | -8 |
| snikket/Stanza.hx | +1 | -1 |
| snikket/jingle/Session.hx | +4 | -4 |
| snikket/persistence/IDB.js | +11 | -8 |
| snikket/persistence/Sqlite.hx | +59 | -50 |
diff --git a/npm/index.ts b/npm/index.ts index f067e2d..05db77a 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -10,6 +10,7 @@ export import Channel = snikket.Channel; export import Chat = snikket.Chat; export import ChatAttachment = snikket.ChatAttachment; export import ChatMessage = snikket.ChatMessage; +export import ChatMessageBuilder = snikket.ChatMessageBuilder; export import Client = snikket.Client; export import Config = snikket.Config; export import CustomEmojiReaction = snikket.CustomEmojiReaction; diff --git a/snikket/Chat.hx b/snikket/Chat.hx index c6431b3..ffd8036 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -91,7 +91,7 @@ abstract class Chat { } @:allow(snikket) - abstract private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza):ChatMessage; + abstract private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza):ChatMessageBuilder; /** Fetch a page of messages before some point @@ -129,7 +129,7 @@ abstract class Chat { for (m in messageList.messages) { switch (m) { case ChatMessageStanza(message): - chatMessages.push(prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() }))); + chatMessages.push(message); case ReactionUpdateStanza(update): persistence.storeReaction(client.accountId(), update, (m)->{}); case ModerateMessageStanza(action): @@ -150,7 +150,7 @@ abstract class Chat { @param message the ChatMessage to send **/ - abstract public function sendMessage(message:ChatMessage):Void; + abstract public function sendMessage(message:ChatMessageBuilder):Void; /** Signals that all messages up to and including this one have probably @@ -186,7 +186,7 @@ abstract class Chat { must be the localId of the first version ever sent, not a subsequent correction @param message the new ChatMessage to replace it with **/ - abstract public function correctMessage(localId:String, message:ChatMessage):Void; + abstract public function correctMessage(localId:String, message:ChatMessageBuilder):Void; /** Add new reaction to a message in this Chat @@ -725,12 +725,12 @@ class DirectChat extends Chat { } @:allow(snikket) - private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) { + private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) { message.syncPoint = !syncing(); return message; } - private function prepareOutgoingMessage(message:ChatMessage) { + private function prepareOutgoingMessage(message:ChatMessageBuilder) { message.timestamp = message.timestamp ?? Date.format(std.Date.now()); message.direction = MessageSent; message.from = client.jid; @@ -741,17 +741,17 @@ class DirectChat extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function correctMessage(localId:String, message:ChatMessage) { - final toSend = prepareOutgoingMessage(message.clone()); + public function correctMessage(localId:String, message:ChatMessageBuilder) { + final toSendId = message.localId; message = prepareOutgoingMessage(message); - message.resetLocalId(); - message.versions = [toSend]; // This is a correction + message.versions = [message.build()]; // This is a correction message.localId = localId; - client.storeMessages([message], (corrected) -> { - toSend.versions = corrected[0].versions[corrected[0].versions.length - 1]?.localId == localId ? corrected[0].versions : [message]; + client.storeMessages([message.build()], (corrected) -> { + message.versions = corrected[0].versions[corrected[0].versions.length - 1]?.localId == localId ? cast corrected[0].versions : [message.build()]; + message.localId = toSendId; for (recipient in message.recipients) { message.to = recipient; - client.sendStanza(toSend.asStanza()); + client.sendStanza(message.build().asStanza()); } if (localId == lastMessage?.localId) { setLastMessage(corrected[0]); @@ -762,17 +762,18 @@ class DirectChat extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function sendMessage(message:ChatMessage):Void { + public function sendMessage(message: ChatMessageBuilder):Void { if (typingTimer != null) typingTimer.stop(); client.chatActivity(this); message = prepareOutgoingMessage(message); - final fromStanza = Message.fromStanza(message.asStanza(), client.jid).parsed; + message.to = message.recipients[0]; // Just pick one for the stanza we re-parse + final fromStanza = Message.fromStanza(message.build().asStanza(), client.jid).parsed; switch (fromStanza) { case ChatMessageStanza(_): - client.storeMessages([message], (stored) -> { + client.storeMessages([message.build()], (stored) -> { for (recipient in message.recipients) { message.to = recipient; - final stanza = message.asStanza(); + final stanza = message.build().asStanza(); if (isActive != null) { isActive = true; activeThread = message.threadId; @@ -780,7 +781,7 @@ class DirectChat extends Chat { } client.sendStanza(stanza); } - setLastMessage(message); + setLastMessage(message.build()); client.trigger("chats/update", [this]); client.notifyMessageHandlers(stored[0], stored[0].versions.length > 1 ? CorrectionEvent : DeliveryEvent); }); @@ -788,7 +789,7 @@ class DirectChat extends Chat { persistence.storeReaction(client.accountId(), update, (stored) -> { for (recipient in message.recipients) { message.to = recipient; - client.sendStanza(message.asStanza()); + client.sendStanza(message.build().asStanza()); } if (stored != null) client.notifyMessageHandlers(stored, ReactionEvent); }); @@ -1032,6 +1033,11 @@ class Channel extends Chat { chatId ); sync.setNewestPageFirst(false); + sync.addContext((builder, stanza) -> { + builder = prepareIncomingMessage(builder, stanza); + builder.syncPoint = true; + return builder; + }); final chatMessages = []; sync.onMessages((messageList) -> { final promises = []; @@ -1042,7 +1048,6 @@ class Channel extends Chat { for (hash in message.inlineHashReferences()) { client.fetchMediaByHash([hash], [message.from]); } - message.syncPoint = true; pageChatMessages.push(message); case ReactionUpdateStanza(update): promises.push(new thenshim.Promise((resolve, reject) -> { @@ -1131,7 +1136,7 @@ class Channel extends Chat { override public function preview() { if (lastMessage == null) return super.preview(); - return getParticipantDetails(lastMessage.senderId()).displayName + ": " + super.preview(); + return getParticipantDetails(lastMessage.senderId).displayName + ": " + super.preview(); } @:allow(snikket) @@ -1188,6 +1193,11 @@ 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.addContext((builder, stanza) -> { + builder = prepareIncomingMessage(builder, stanza); + builder.syncPoint = false; + return builder; + }); fetchFromSync(sync, handler); } }); @@ -1206,6 +1216,11 @@ class Channel extends Chat { var filter:MAMQueryParams = {}; if (afterId != null) filter.page = { after: afterId }; var sync = new MessageSync(this.client, this.stream, filter, chatId); + sync.addContext((builder, stanza) -> { + builder = prepareIncomingMessage(builder, stanza); + builder.syncPoint = false; + return builder; + }); fetchFromSync(sync, handler); } }); @@ -1224,18 +1239,18 @@ class Channel extends Chat { } @:allow(snikket) - private function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) { + private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) { message.syncPoint = !syncing(); if (message.type == MessageChat) message.type = MessageChannelPrivate; message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs - if (message.senderId() == getFullJid().asString()) { + if (message.senderId == getFullJid().asString()) { message.recipients = message.replyTo; message.direction = MessageSent; } return message; } - private function prepareOutgoingMessage(message:ChatMessage) { + private function prepareOutgoingMessage(message:ChatMessageBuilder) { message.type = MessageChannel; message.timestamp = message.timestamp ?? Date.format(std.Date.now()); message.direction = MessageSent; @@ -1248,15 +1263,15 @@ class Channel extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function correctMessage(localId:String, message:ChatMessage) { - final toSend = prepareOutgoingMessage(message.clone()); + public function correctMessage(localId:String, message:ChatMessageBuilder) { + final toSendId = message.localId; message = prepareOutgoingMessage(message); - message.resetLocalId(); - message.versions = [toSend]; // This is a correction + message.versions = [message.build()]; // This is a correction message.localId = localId; - client.storeMessages([message], (corrected) -> { - toSend.versions = corrected[0].localId == localId ? corrected[0].versions : [message]; - client.sendStanza(toSend.asStanza()); + client.storeMessages([message.build()], (corrected) -> { + message.versions = corrected[0].localId == localId ? cast corrected[0].versions : [message.build()]; + message.localId = toSendId; + client.sendStanza(message.build().asStanza()); client.notifyMessageHandlers(corrected[0], CorrectionEvent); if (localId == lastMessage?.localId) { setLastMessage(corrected[0]); @@ -1266,11 +1281,11 @@ class Channel extends Chat { } @HaxeCBridge.noemit // on superclass as abstract - public function sendMessage(message:ChatMessage):Void { + public function sendMessage(message:ChatMessageBuilder):Void { if (typingTimer != null) typingTimer.stop(); client.chatActivity(this); message = prepareOutgoingMessage(message); - final stanza = message.asStanza(); + final stanza = message.build().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).parsed; @@ -1282,7 +1297,7 @@ class Channel extends Chat { activeThread = message.threadId; stanza.tag("active", { xmlns: "http://jabber.org/protocol/chatstates" }).up(); } - client.storeMessages([message], (stored) -> { + client.storeMessages([message.build()], (stored) -> { client.sendStanza(stanza); setLastMessage(stored[0]); client.notifyMessageHandlers(stored[0], stored[0].versions.length > 1 ? CorrectionEvent : DeliveryEvent); diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx index 25d3cfa..daeed14 100644 --- a/snikket/ChatMessage.hx +++ b/snikket/ChatMessage.hx @@ -1,10 +1,11 @@ package snikket; import datetime.DateTime; +import haxe.Exception; import haxe.crypto.Base64; +import haxe.ds.ReadOnlyArray; import haxe.io.Bytes; import haxe.io.BytesData; -import haxe.Exception; using Lambda; using StringTools; @@ -23,7 +24,7 @@ import snikket.Stanza; import snikket.Util; @:expose -@:nullSafety(Strict) +@:nullSafety(StrictThreaded) #if cpp @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) @@ -32,8 +33,8 @@ class ChatAttachment { public final name: Null<String>; public final mime: String; public final size: Null<Int>; - public final uris: Array<String>; - public final hashes: Array<Hash>; + public final uris: ReadOnlyArray<String>; + public final hashes: ReadOnlyArray<Hash>; #if cpp @:allow(snikket) @@ -51,7 +52,7 @@ class ChatAttachment { } @:expose -@:nullSafety(Strict) +@:nullSafety(StrictThreaded) #if cpp @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) @@ -60,94 +61,155 @@ class ChatMessage { /** The ID as set by the creator of this message **/ - public var localId (default, set) : Null<String> = null; + public final localId: Null<String>; + /** The ID as set by the authoritative server **/ - public var serverId (default, set) : Null<String> = null; + public final serverId: Null<String>; + /** The ID of the server which set the serverId **/ - public var serverIdBy : Null<String> = null; + public final serverIdBy: Null<String>; + /** The type of this message (Chat, Call, etc) **/ - public var type : MessageType = MessageChat; + public final type: MessageType; @:allow(snikket) - private var syncPoint : Bool = false; + private final syncPoint : Bool; @:allow(snikket) - private var replyId : Null<String> = null; + private final replyId : Null<String>; /** - The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]+00:00 + The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]Z **/ - public var timestamp (default, set) : Null<String> = null; + public final timestamp: String; @:allow(snikket) - private var to: Null<JID> = null; - @:allow(snikket) - private var from: Null<JID> = null; + private final to: JID; @:allow(snikket) - private var sender: Null<JID> = null; + private final from: JID; @:allow(snikket) - private var recipients: Array<JID> = []; + private final recipients: ReadOnlyArray<JID>; @:allow(snikket) - private var replyTo: Array<JID> = []; + private final replyTo: ReadOnlyArray<JID>; + + /** + The ID of the sender of this message + **/ + public final senderId: String; /** Message this one is in reply to, or NULL **/ - public var replyToMessage: Null<ChatMessage> = null; + public var replyToMessage(default, null): Null<ChatMessage>; + /** ID of the thread this message is in, or NULL **/ - public var threadId: Null<String> = null; + public final threadId: Null<String>; /** Array of attachments to this message **/ - public var attachments (default, null): Array<ChatAttachment> = []; + public final attachments: ReadOnlyArray<ChatAttachment>; + /** Map of reactions to this message **/ @HaxeCBridge.noemit - public var reactions: Map<String, Array<Reaction>> = []; + public var reactions(default, null): Map<String, Array<Reaction>>; /** Body text of this message or NULL **/ - public var text: Null<String> = null; + public final text: Null<String>; + /** Language code for the body text **/ - public var lang: Null<String> = null; + public final lang: Null<String>; /** Direction of this message **/ - public var direction: MessageDirection = MessageReceived; + public final direction: MessageDirection; + /** Status of this message **/ - public var status: MessageStatus = MessagePending; + public var status: MessageStatus; + /** Array of past versions of this message, if it has been edited **/ @:allow(snikket) - public var versions (default, null): Array<ChatMessage> = []; + public final versions: ReadOnlyArray<ChatMessage>; + @:allow(snikket, test) - private var payloads: Array<Stanza> = []; + private final payloads: ReadOnlyArray<Stanza>; - /** - @returns a new blank ChatMessage - **/ - public function new() { } + @:allow(snikket) + public final stanza: Null<Stanza>; @:allow(snikket) - private static function fromStanza(stanza:Stanza, localJid:JID):Null<ChatMessage> { - switch Message.fromStanza(stanza, localJid).parsed { + private function new(params: { + ?localId: Null<String>, + ?serverId: Null<String>, + ?serverIdBy: Null<String>, + ?type: MessageType, + ?syncPoint: Bool, + ?replyId: Null<String>, + timestamp: String, + to: JID, + from: JID, + senderId: String, + ?recipients: Array<JID>, + ?replyTo: Array<JID>, + ?replyToMessage: Null<ChatMessage>, + ?threadId: Null<String>, + ?attachments: Array<ChatAttachment>, + ?reactions: Map<String, Array<Reaction>>, + ?text: Null<String>, + ?lang: Null<String>, + ?direction: MessageDirection, + ?status: MessageStatus, + ?versions: Array<ChatMessage>, + ?payloads: Array<Stanza>, + ?stanza: Null<Stanza>, + }) { + this.localId = params.localId; + this.serverId = params.serverId; + this.serverIdBy = params.serverIdBy; + this.type = params.type ?? MessageChat; + this.syncPoint = params.syncPoint ?? false; + this.replyId = params.replyId; + this.timestamp = params.timestamp; + this.to = params.to; + this.from = params.from; + this.senderId = params.senderId; + this.recipients = params.recipients ?? []; + this.replyTo = params.replyTo ?? []; + this.replyToMessage = params.replyToMessage; + this.threadId = params.threadId; + this.attachments = params.attachments ?? []; + this.reactions = params.reactions ?? ([] : Map<String, Array<Reaction>>); + this.text = params.text; + this.lang = params.lang; + this.direction = params.direction ?? MessageSent; + this.status = params.status ?? MessagePending; + this.versions = params.versions ?? []; + this.payloads = params.payloads ?? []; + this.stanza = params.stanza; + } + + @:allow(snikket) + private static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Null<ChatMessage> { + switch Message.fromStanza(stanza, localJid, addContext).parsed { case ChatMessageStanza(message): return message; default: @@ -155,31 +217,11 @@ class ChatMessage { } } - @:allow(snikket) - private function attachSims(sims: Stanza) { - var mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/media-type#"); - if (mime == null) mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/media-type#"); - if (mime == null) mime = "application/octet-stream"; - var name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/name#"); - if (name == null) name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/name#"); - var size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/size#"); - if (size == null) size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/size#"); - final hashes = ((sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:5") ?? sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:3")) - ?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> new Hash(hash.attr.get("algo") ?? "", Base64.decode(hash.getText()).getData())); - final sources = sims.getChild("sources"); - final uris = (sources?.allTags("reference", "urn:xmpp:reference:0") ?? []).map((ref) -> ref.attr.get("uri") ?? "").filter((uri) -> uri != ""); - if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes)); - } - - public function addAttachment(attachment: ChatAttachment) { - attachments.push(attachment); - } - /** Create a new ChatMessage in reply to this one **/ public function reply() { - final m = new ChatMessage(); + final m = new ChatMessageBuilder(); m.type = type; m.threadId = threadId ?? ID.long(); m.replyToMessage = this; @@ -192,42 +234,18 @@ class ChatMessage { } @:allow(snikket) - private function makeModerated(timestamp: String, moderatorId: Null<String>, reason: Null<String>) { - text = null; - attachments = []; - payloads = []; - versions = []; - final cleanedStub = clone(); - final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp }); - if (reason != null) payload.textTag("reason", reason); - payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up(); - payloads.push(payload); - final head = clone(); - head.timestamp = timestamp; - versions = [head, cleanedStub]; - } - - private function set_localId(localId:Null<String>) { - if(this.localId != null) { - throw new Exception("Message already has a localId set"); - } - return this.localId = localId; - } - - private function set_serverId(serverId:Null<String>) { - if(this.serverId != null && this.serverId != serverId) { - throw new Exception("Message already has a serverId set"); - } - return this.serverId = serverId; - } - - private function set_timestamp(timestamp:Null<String>) { - return this.timestamp = timestamp; + private function set_replyToMessage(m: ChatMessage) { + final rtm = replyToMessage; + if (rtm == null) throw "Cannot hydrate null replyToMessage"; + if (rtm.serverId != null && rtm.serverId != m.serverId) throw "Hydrate serverId mismatch"; + if (rtm.localId != null && rtm.localId != m.localId) throw "Hydrate localId mismatch"; + return replyToMessage = m; } @:allow(snikket) - private function resetLocalId() { - Reflect.setField(this, "localId", null); + private function set_reactions(r: Map<String, Array<Reaction>>) { + if (reactions != null && !{ iterator: () -> reactions.keys() }.empty()) throw "Reactions already hydrated"; + return reactions = r; } @:allow(snikket) @@ -293,50 +311,6 @@ class ChatMessage { 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); } - /** - Set rich text using an HTML string - Also sets the plain text body appropriately - **/ - public function setHtml(html: String) { - final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" }); - final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" }); - htmlEl.addChild(body); - final nodes = htmlparser.HtmlParser.run(html, true); - for (node in nodes) { - final el = Util.downcast(node, htmlparser.HtmlNodeElement); - if (el != null && (el.name == "html" || el.name == "body")) { - for (inner in el.nodes) { - body.addDirectChild(htmlToNode(inner)); - } - } else { - body.addDirectChild(htmlToNode(node)); - } - } - final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html"); - if (htmlIdx >= 0) payloads.splice(htmlIdx, 1); - payloads.push(htmlEl); - text = XEP0393.render(body); - } - - private function htmlToNode(node: htmlparser.HtmlNode) { - final txt = Util.downcast(node, htmlparser.HtmlNodeText); - if (txt != null) { - return CData(new TextNode(txt.toText())); - } - final el = Util.downcast(node, htmlparser.HtmlNodeElement); - if (el != null) { - final s = new Stanza(el.name, {}); - for (attr in el.attributes) { - s.attr.set(attr.name, attr.value); - } - for (child in el.nodes) { - s.addDirectChild(htmlToNode(child)); - } - return Element(s); - } - throw "node was neither text nor element?"; - } - /** The ID of the Chat this message is associated with **/ @@ -348,13 +322,6 @@ class ChatMessage { } } - /** - The ID of the sender of this message - **/ - public function senderId():String { - return sender?.asString() ?? throw "sender is null"; - } - /** The ID of the account associated with this message **/ @@ -418,6 +385,8 @@ class ChatMessage { @:allow(snikket) private function asStanza():Stanza { + if (stanza != null) return stanza; + var body = text; var attrs: haxe.DynamicAccess<String> = { type: type == MessageChannel ? "groupchat" : "chat" }; if (from != null) attrs.set("from", from.asString()); @@ -462,7 +431,7 @@ class ChatMessage { addedReactions[reaction] = true; for (areaction => reactions in replyToM.reactions) { - if (!(addedReactions[areaction] ?? false) && reactions.find(r -> r.senderId == senderId()) != null) { + if (!(addedReactions[areaction] ?? false) && reactions.find(r -> r.senderId == senderId) != null) { addedReactions[areaction] = true; stanza.textTag("reaction", areaction); } @@ -521,20 +490,4 @@ class ChatMessage { } return stanza; } - - /** - Duplicate this ChatMessage - **/ - public function clone() { - final cls:Class<ChatMessage> = untyped Type.getClass(this); - final inst = Type.createEmptyInstance(cls); - final fields = Type.getInstanceFields(cls); - for (field in fields) { - final val:Dynamic = Reflect.field(this, field); - if (!Reflect.isFunction(val)) { - Reflect.setField(inst,field,val); - } - } - return inst; - } } diff --git a/snikket/ChatMessageBuilder.hx b/snikket/ChatMessageBuilder.hx new file mode 100644 index 0000000..7e255ee --- /dev/null +++ b/snikket/ChatMessageBuilder.hx @@ -0,0 +1,333 @@ +package snikket; + +import datetime.DateTime; +import haxe.crypto.Base64; +import haxe.io.Bytes; +import haxe.io.BytesData; +import haxe.Exception; +using Lambda; +using StringTools; + +#if cpp +import HaxeCBridge; +#end + +import snikket.Hash; +import snikket.JID; +import snikket.Identicon; +import snikket.StringUtil; +import snikket.XEP0393; +import snikket.EmojiUtil; +import snikket.Message; +import snikket.Stanza; +import snikket.Util; +import snikket.ChatMessage; + +@:expose +@:nullSafety(StrictThreaded) +#if cpp +@:build(HaxeCBridge.expose()) +@:build(HaxeSwiftBridge.expose()) +#end +class ChatMessageBuilder { + /** + The ID as set by the creator of this message + **/ + public var localId: Null<String> = null; + + /** + The ID as set by the authoritative server + **/ + public var serverId: Null<String> = null; + + /** + The ID of the server which set the serverId + **/ + public var serverIdBy: Null<String> = null; + + /** + The type of this message (Chat, Call, etc) + **/ + public var type: MessageType = MessageChat; + + @:allow(snikket) + private var syncPoint: Bool = false; + + @:allow(snikket) + private var replyId: Null<String> = null; + + /** + The timestamp of this message, in format YYYY-MM-DDThh:mm:ss[.sss]+00:00 + **/ + public var timestamp: Null<String> = null; + + @:allow(snikket) + private var to: Null<JID> = null; + @:allow(snikket) + private var from: Null<JID> = null; + @:allow(snikket) + private var sender: Null<JID> = null; // DEPRECATED + @:allow(snikket) + private var recipients: Array<JID> = []; + @:allow(snikket) + private var replyTo: Array<JID> = []; + + public var senderId (get, default): Null<String> = null; + + /** + Message this one is in reply to, or NULL + **/ + public var replyToMessage: Null<ChatMessage> = null; + + /** + ID of the thread this message is in, or NULL + **/ + public var threadId: Null<String> = null; + + /** + Array of attachments to this message + **/ + public var attachments (default, null): Array<ChatAttachment> = []; + + /** + Map of reactions to this message + **/ + @HaxeCBridge.noemit + public var reactions: Map<String, Array<Reaction>> = []; + + /** + Body text of this message or NULL + **/ + public var text: Null<String> = null; + + /** + Language code for the body text + **/ + public var lang: Null<String> = null; + + /** + Direction of this message + **/ + public var direction: MessageDirection = MessageReceived; + + /** + Status of this message + **/ + public var status: MessageStatus = MessagePending; + + /** + Array of past versions of this message, if it has been edited + **/ + @:allow(snikket) + public var versions (default, null): Array<ChatMessage> = []; + + @:allow(snikket, test) + private var payloads: Array<Stanza> = []; + + /** + WARNING: if you set this, you promise all the attributes of this builder match it + **/ + @:allow(snikket) + private var stanza: Null<Stanza> = null; + + /** + @returns a new blank ChatMessageBuilder + **/ + #if cpp + public function new() { } + #else + public function new(?params: { + ?localId: Null<String>, + ?serverId: Null<String>, + ?serverIdBy: Null<String>, + ?type: MessageType, + ?syncPoint: Bool, + ?replyId: Null<String>, + ?timestamp: String, + ?senderId: String, + ?replyToMessage: Null<ChatMessage>, + ?threadId: Null<String>, + ?attachments: Array<ChatAttachment>, + ?reactions: Map<String, Array<Reaction>>, + ?text: Null<String>, + ?lang: Null<String>, + ?direction: MessageDirection, + ?status: MessageStatus, + ?versions: Array<ChatMessage>, + ?payloads: Array<Stanza>, + ?html: Null<String>, + }) { + this.localId = params?.localId; + this.serverId = params?.serverId; + this.serverIdBy = params?.serverIdBy; + this.type = params?.type ?? MessageChat; + this.syncPoint = params?.syncPoint ?? false; + this.replyId = params?.replyId; + this.timestamp = params?.timestamp; + this.senderId = params?.senderId; + this.replyToMessage = params?.replyToMessage; + this.threadId = params?.threadId; + this.attachments = params?.attachments ?? []; + this.reactions = params?.reactions ?? ([] : Map<String, Array<Reaction>>); + this.text = params?.text; + this.lang = params?.lang; + this.direction = params?.direction ?? MessageSent; + this.status = params?.status ?? MessagePending; + this.versions = params?.versions ?? []; + this.payloads = params?.payloads ?? []; + final html = params?.html; + if (html != null) setHtml(html); + } + #end + + @:allow(snikket) + private static function makeModerated(m: ChatMessage, timestamp: String, moderatorId: Null<String>, reason: Null<String>) { + final builder = new ChatMessageBuilder(); + builder.localId = m.localId; + builder.serverId = m.serverId; + builder.serverIdBy = m.serverIdBy; + builder.type = m.type; + builder.syncPoint = m.syncPoint; + builder.replyId = m.replyId; + builder.timestamp = m.timestamp; + builder.to = m.to; + builder.from = m.from; + builder.senderId = m.senderId; + builder.recipients = m.recipients.array(); + builder.replyTo = m.replyTo.array(); + builder.replyToMessage = m.replyToMessage; + builder.threadId = m.threadId; + builder.reactions = m.reactions; + builder.direction = m.direction; + builder.status = m.status; + final cleanedStub = builder.build(); + final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp }); + if (reason != null) payload.textTag("reason", reason); + payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up(); + builder.payloads.push(payload); + builder.timestamp = timestamp; + builder.versions = [builder.build(), cleanedStub]; + builder.timestamp = m.timestamp; + return builder.build(); + } + + @:allow(snikket) + private function attachSims(sims: Stanza) { + var mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/media-type#"); + if (mime == null) mime = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/media-type#"); + if (mime == null) mime = "application/octet-stream"; + var name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/name#"); + if (name == null) name = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/name#"); + var size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:5}/size#"); + if (size == null) size = sims.findText("{urn:xmpp:jingle:apps:file-transfer:3}/size#"); + final hashes = ((sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:5") ?? sims.getChild("file", "urn:xmpp:jingle:apps:file-transfer:3")) + ?.allTags("hash", "urn:xmpp:hashes:2") ?? []).map((hash) -> new Hash(hash.attr.get("algo") ?? "", Base64.decode(hash.getText()).getData())); + final sources = sims.getChild("sources"); + final uris = (sources?.allTags("reference", "urn:xmpp:reference:0") ?? []).map((ref) -> ref.attr.get("uri") ?? "").filter((uri) -> uri != ""); + if (uris.length > 0) attachments.push(new ChatAttachment(name, mime, size == null ? null : Std.parseInt(size), uris, hashes)); + } + + public function addAttachment(attachment: ChatAttachment) { + attachments.push(attachment); + } + + /** + Set rich text using an HTML string + Also sets the plain text body appropriately + **/ + public function setHtml(html: String) { + final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" }); + final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" }); + htmlEl.addChild(body); + final nodes = htmlparser.HtmlParser.run(html, true); + for (node in nodes) { + final el = Util.downcast(node, htmlparser.HtmlNodeElement); + if (el != null && (el.name == "html" || el.name == "body")) { + for (inner in el.nodes) { + body.addDirectChild(htmlToNode(inner)); + } + } else { + body.addDirectChild(htmlToNode(node)); + } + } + final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html"); + if (htmlIdx >= 0) payloads.splice(htmlIdx, 1); + payloads.push(htmlEl); + text = XEP0393.render(body); + } + + private function htmlToNode(node: htmlparser.HtmlNode) { + final txt = Util.downcast(node, htmlparser.HtmlNodeText); + if (txt != null) { + return CData(new TextNode(txt.toText())); + } + final el = Util.downcast(node, htmlparser.HtmlNodeElement); + if (el != null) { + final s = new Stanza(el.name, {}); + for (attr in el.attributes) { + s.attr.set(attr.name, attr.value); + } + for (child in el.nodes) { + s.addDirectChild(htmlToNode(child)); + } + return Element(s); + } + throw "node was neither text nor element?"; + } + + /** + The ID of the Chat this message is associated with + **/ + public function chatId():String { + if (isIncoming()) { + return replyTo.map((r) -> r.asBare().asString()).join("\n"); + } else { + return recipients.map((r) -> r.asString()).join("\n"); + } + } + + /** + The ID of the sender of this message + **/ + public function get_senderId():String { + return senderId ?? sender?.asString() ?? throw "sender is null"; + } + + public function isIncoming():Bool { + return direction == MessageReceived; + } + + public function build() { + if (serverId == null && localId == null) throw "Cannot build a ChatMessage with no id"; + final to = this.to; + if (to == null) throw "Cannot build a ChatMessage with no to"; + final from = this.from; + if (from == null) throw "Cannot build a ChatMessage with no from"; + final sender = this.sender ?? from.asBare(); + return new ChatMessage({ + localId: localId, + serverId: serverId, + serverIdBy: serverIdBy, + type: type, + syncPoint: syncPoint, + replyId: replyId, + timestamp: timestamp ?? Date.format(std.Date.now()), + to: to, + from: from, + senderId: senderId, + recipients: recipients, + replyTo: replyTo, + replyToMessage: replyToMessage, + threadId: threadId, + attachments: attachments, + reactions: reactions, + text: text, + lang: lang, + direction: direction, + status: status, + versions: versions, + payloads: payloads, + stanza: stanza, + }); + } +} diff --git a/snikket/Client.hx b/snikket/Client.hx index be501f3..f204c03 100644 --- a/snikket/Client.hx +++ b/snikket/Client.hx @@ -162,14 +162,18 @@ class Client extends EventEmitter { } } - final message = Message.fromStanza(stanza, this.jid); + final message = Message.fromStanza(stanza, this.jid, (builder, stanza) -> { + var chat = getChat(builder.chatId()); + if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(builder.chatId()); + if (chat == null) return builder; + return chat.prepareIncomingMessage(builder, stanza); + }); switch (message.parsed) { case ChatMessageStanza(chatMessage): for (hash in chatMessage.inlineHashReferences()) { fetchMediaByHash([hash], [chatMessage.from]); } - var chat = getChat(chatMessage.chatId()); - if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId()); + final chat = getChat(chatMessage.chatId()); if (chat != null) { final updateChat = (chatMessage) -> { notifyMessageHandlers(chatMessage, chatMessage.versions.length > 1 ? CorrectionEvent : DeliveryEvent); @@ -179,7 +183,6 @@ class Client extends EventEmitter { chatActivity(chat); } }; - chatMessage = chat.prepareIncomingMessage(chatMessage, stanza); if (chatMessage.serverId == null) { updateChat(chatMessage); } else { @@ -915,7 +918,7 @@ class Client extends EventEmitter { persistence.removeMedia(hash.algorithm, hash.hash); } } - moderateMessage.makeModerated(action.timestamp, action.moderatorId, action.reason); + moderateMessage = ChatMessageBuilder.makeModerated(moderateMessage, action.timestamp, action.moderatorId, action.reason); persistence.updateMessage(accountId(), moderateMessage); resolve(moderateMessage); }) @@ -1435,13 +1438,16 @@ class Client extends EventEmitter { lastId == null ? { startTime: thirtyDaysAgo } : { page: { after: lastId } } ); sync.setNewestPageFirst(false); + sync.addContext((builder, stanza) -> { + builder.syncPoint = true; + return builder; + }); sync.onMessages((messageList) -> { final promises = []; final chatMessages = []; for (m in messageList.messages) { switch (m) { case ChatMessageStanza(message): - message.syncPoint = true; chatMessages.push(message); case ReactionUpdateStanza(update): promises.push(new thenshim.Promise((resolve, reject) -> { diff --git a/snikket/Message.hx b/snikket/Message.hx index 620eafa..860d1ed 100644 --- a/snikket/Message.hx +++ b/snikket/Message.hx @@ -45,14 +45,14 @@ class Message { this.parsed = parsed; } - public static function fromStanza(stanza:Stanza, localJid:JID, ?inputTimestamp: String):Message { + public static function fromStanza(stanza:Stanza, localJid:JID, ?addContext: (ChatMessageBuilder, Stanza)->ChatMessageBuilder):Message { final fromAttr = stanza.attr.get("from"); final from = fromAttr == null ? localJid.domain : fromAttr; if (stanza.attr.get("type") == "error") return new Message(from, from, null, ErrorMessageStanza(stanza)); - var msg = new ChatMessage(); - final timestamp = stanza.findText("{urn:xmpp:delay}delay@stamp") ?? inputTimestamp ?? Date.format(std.Date.now()); - msg.timestamp = timestamp; + var msg = new ChatMessageBuilder(); + msg.stanza = stanza; + msg.timestamp =stanza.findText("{urn:xmpp:delay}delay@stamp"); msg.threadId = stanza.getChildText("thread"); msg.lang = stanza.attr.get("xml:lang"); msg.text = stanza.getChildText("body"); @@ -62,7 +62,7 @@ class Message { msg.from = JID.parse(from); final isGroupchat = stanza.attr.get("type") == "groupchat"; msg.type = isGroupchat ? MessageChannel : MessageChat; - msg.sender = isGroupchat ? msg.from : msg.from?.asBare(); + msg.senderId = (isGroupchat ? msg.from : msg.from?.asBare())?.asString(); final localJidBare = localJid.asBare(); final domain = localJid.domain; final to = stanza.attr.get("to"); @@ -126,7 +126,7 @@ class Message { replyTo.clear(); } else if (jid == null) { trace("No support for addressing to non-jid", address); - return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza)); + return new Message(msg.chatId(), msg.senderId, msg.threadId, 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 @@ -137,9 +137,9 @@ class Message { } replyTo[JID.parse(jid).asString()] = true; } else if (address.attr.get("type") == "ofrom") { - if (JID.parse(jid).domain == msg.sender?.domain) { + if (JID.parse(jid).domain == msg.from?.domain) { // TODO: check that domain supports extended addressing - msg.sender = JID.parse(jid).asBare(); + msg.senderId = JID.parse(jid).asBare().asString(); } } } @@ -153,24 +153,28 @@ class Message { 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 new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza)); + return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza)); } + if (addContext != null) msg = addContext(msg, stanza); + final timestamp = msg.timestamp ?? Date.format(std.Date.now()); + msg.timestamp = timestamp; + 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 new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate( + 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(), - msg.senderId(), + msg.senderId, timestamp, - reactions.map(text -> new Reaction(msg.senderId(), timestamp, text, msg.localId)), + reactions.map(text -> new Reaction(msg.senderId, timestamp, text, msg.localId)), EmojiReactions ))); } @@ -193,10 +197,10 @@ class Message { msg.payloads.push(jmi); if (msg.text == null) msg.text = "call " + jmi.name; if (jmi.name != "propose") { - msg.versions = [msg.clone()]; + msg.versions = [msg.build()]; } // The session id is what really identifies us - Reflect.setField(msg, "localId", jmi.attr.get("id")); + msg.localId = jmi.attr.get("id"); } final retract = stanza.getChild("replace", "urn:xmpp:message-retract:1"); @@ -209,7 +213,7 @@ class Message { // TODO: occupant id as well / instead of by? return new Message( msg.chatId(), - msg.senderId(), + msg.senderId, msg.threadId, ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason)) ); @@ -218,7 +222,7 @@ class Message { final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0"); final replaceId = replace?.attr?.get("id"); - if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza)); + if (msg.text == null && msg.attachments.length < 1 && replaceId == null) return new Message(msg.chatId(), msg.senderId, msg.threadId, UnknownMessageStanza(stanza)); for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) { msg.payloads.push(fallback); @@ -241,15 +245,15 @@ class Message { final text = msg.text; if (text != null && EmojiUtil.isOnlyEmoji(text.trim())) { - return new Message(msg.chatId(), msg.senderId(), msg.threadId, ReactionUpdateStanza(new ReactionUpdate( + 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(), - msg.senderId(), + msg.senderId, timestamp, - [new Reaction(msg.senderId(), timestamp, text.trim(), msg.localId)], + [new Reaction(msg.senderId, timestamp, text.trim(), msg.localId)], AppendReactions ))); } @@ -261,15 +265,15 @@ class Message { 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( + 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(), - msg.senderId(), + msg.senderId, timestamp, - [new CustomEmojiReaction(msg.senderId(), timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri(), msg.localId)], + [new CustomEmojiReaction(msg.senderId, timestamp, els[0].attr.get("alt") ?? "", hash.serializeUri(), msg.localId)], AppendReactions ))); } @@ -279,24 +283,25 @@ class Message { if (replyToID != null) { // Reply stub - final replyToMessage = new ChatMessage(); + final replyToMessage = new ChatMessageBuilder(); + replyToMessage.to = replyToJid == msg.senderId ? msg.to : msg.from; replyToMessage.from = replyToJid == null ? null : JID.parse(replyToJid); - replyToMessage.sender = replyToMessage.from; + replyToMessage.senderId = replyToMessage.from?.asString(); replyToMessage.replyId = replyToID; - if (isGroupchat) { + if (msg.serverIdBy != null && msg.serverIdBy != localJid.asBare().asString()) { replyToMessage.serverId = replyToID; } else { replyToMessage.localId = replyToID; } - msg.replyToMessage = replyToMessage; + msg.replyToMessage = replyToMessage.build(); } } if (replaceId != null) { - msg.versions = [msg.clone()]; - Reflect.setField(msg, "localId", replaceId); + msg.versions = [msg.build()]; + msg.localId = replaceId; } - return new Message(msg.chatId(), msg.senderId(), msg.threadId, ChatMessageStanza(msg)); + return new Message(msg.chatId(), msg.senderId, msg.threadId, ChatMessageStanza(msg.build())); } } diff --git a/snikket/MessageSync.hx b/snikket/MessageSync.hx index cbca577..0c9bb79 100644 --- a/snikket/MessageSync.hx +++ b/snikket/MessageSync.hx @@ -24,6 +24,7 @@ class MessageSync { private var filter:MessageFilter; private var serviceJID:String; private var handler:MessageListHandler; + private var contextHandler:(ChatMessageBuilder, Stanza)->ChatMessageBuilder = (b,_)->b; private var errorHandler:(Stanza)->Void; public var lastPage(default, null):ResultSetPageResult; public var progress(default, null): Int = 0; @@ -82,14 +83,12 @@ class MessageSync { jmi.set(jmiChildren[0].attr.get("id"), originalMessage); } - final msg = Message.fromStanza(originalMessage, client.jid, timestamp).parsed; - - switch (msg) { - case ChatMessageStanza(chatMessage): - chatMessage.serverId = result.attr.get("id"); - chatMessage.serverIdBy = serviceJID; - default: - } + final msg = Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> { + builder.serverId = result.attr.get("id"); + builder.serverIdBy = serviceJID; + if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp; + return contextHandler(builder, stanza); + }).parsed; messages.push(msg); @@ -120,6 +119,10 @@ class MessageSync { return !complete; } + public function addContext(handler: (ChatMessageBuilder, Stanza)->ChatMessageBuilder) { + this.contextHandler = handler; + } + public function onMessages(handler:MessageListHandler):Void { this.handler = handler; } diff --git a/snikket/Stanza.hx b/snikket/Stanza.hx index dcce07d..27b345e 100644 --- a/snikket/Stanza.hx +++ b/snikket/Stanza.hx @@ -307,7 +307,7 @@ class Stanza implements NodeInterface { }; } - public function findText(path:String):String { + public function findText(path:String):Null<String> { var result = find(path); if(result == null) { return null; diff --git a/snikket/jingle/Session.hx b/snikket/jingle/Session.hx index 7ce4fcb..417b380 100644 --- a/snikket/jingle/Session.hx +++ b/snikket/jingle/Session.hx @@ -38,7 +38,7 @@ interface Session { } private function mkCallMessage(to: JID, from: JID, event: Stanza) { - final m = new ChatMessage(); + final m = new ChatMessageBuilder(); m.type = MessageCall; m.to = to; m.recipients = [to.asBare()]; @@ -51,10 +51,10 @@ private function mkCallMessage(to: JID, from: JID, event: Stanza) { m.payloads.push(event); m.localId = ID.long(); if (event.name != "propose") { - m.versions = [m.clone()]; + m.versions = [m.build()]; } - Reflect.setField(m, "localId", event.attr.get("id")); - return m; + m.localId = event.attr.get("id"); + return m.build(); } class IncomingProposedSession implements Session { diff --git a/snikket/persistence/IDB.js b/snikket/persistence/IDB.js index f88c938..4f30c56 100644 --- a/snikket/persistence/IDB.js +++ b/snikket/persistence/IDB.js @@ -89,7 +89,7 @@ export default (dbname, media, tokenize, stemmer) => { const tx = db.transaction(["messages"], "readonly"); const store = tx.objectStore("messages"); - const message = new snikket.ChatMessage(); + const message = new snikket.ChatMessageBuilder(); message.localId = value.localId ? value.localId : null; message.serverId = value.serverId ? value.serverId : null; message.serverIdBy = value.serverIdBy ? value.serverIdBy : null; @@ -101,6 +101,7 @@ export default (dbname, media, tokenize, stemmer) => { message.to = value.to && snikket.JID.parse(value.to); message.from = value.from && snikket.JID.parse(value.from); message.sender = value.sender && snikket.JID.parse(value.sender); + message.senderId = value.senderId; message.recipients = value.recipients.map((r) => snikket.JID.parse(r)); message.replyTo = value.replyTo.map((r) => snikket.JID.parse(r)); message.threadId = value.threadId; @@ -110,7 +111,8 @@ export default (dbname, media, tokenize, stemmer) => { message.lang = value.lang; message.type = value.type || (value.isGroupchat || value.groupchat ? enums.MessageType.Channel : enums.MessageType.Chat); message.payloads = (value.payloads || []).map(snikket.Stanza.parse); - return message; + message.stanza = value.stanza && snikket.Stanza.parse(value.stanza); + return message.build(); } async function hydrateMessage(value) { @@ -137,13 +139,14 @@ export default (dbname, media, tokenize, stemmer) => { chatId: message.chatId(), to: message.to?.asString(), from: message.from?.asString(), - sender: message.sender?.asString(), + senderId: message.senderId, 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 || ""], versions: message.versions.map((m) => serializeMessage(account, m)), payloads: message.payloads.map((p) => p.toString()), + stanza: message.stanza?.toString(), } } @@ -165,7 +168,7 @@ export default (dbname, media, tokenize, stemmer) => { // Calls can "edit" from multiple senders, but the original direction and sender holds if (result.value.type === enums.MessageType.MessageCall) { head.direction = result.value.direction; - head.sender = result.value.sender; + head.senderId = result.value.senderId; head.from = result.value.from; head.to = result.value.to; head.replyTo = result.value.replyTo; @@ -373,7 +376,7 @@ export default (dbname, media, tokenize, stemmer) => { const store = tx.objectStore("messages"); 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 || ""]))) + 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) => { @@ -381,16 +384,16 @@ export default (dbname, media, tokenize, stemmer) => { 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); + 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); + 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) { // 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)) { + } else if (result?.value && (result.value.sender == message.senderId || result.value.type == enums.MessageType.MessageCall) && (message.versions.length > 0 || (result.value.versions || []).length > 0)) { hydrateMessage(correctMessage(account, message, result)).then(callback); return true; } diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx index 307ba82..1f1b42e 100644 --- a/snikket/persistence/Sqlite.hx +++ b/snikket/persistence/Sqlite.hx @@ -48,9 +48,11 @@ class Sqlite implements Persistence implements KeyValueStore { correction_id TEXT NOT NULL, sync_point INTEGER NOT NULL, chat_id TEXT NOT NULL, + sender_id TEXT NOT NULL, created_at INTEGER NOT NULL, status INTEGER NOT NULL, direction INTEGER NOT NULL, + type INTEGER NOT NULL, stanza TEXT NOT NULL, PRIMARY KEY (account_id, mam_id, mam_by, stanza_id) ); @@ -293,14 +295,15 @@ class Sqlite implements Persistence implements KeyValueStore { Promise.resolve(null); }).then(_ -> db.exec( - "INSERT OR REPLACE INTO messages VALUES " + messages.map(_ -> "(?,?,?,?,?,?,?,CAST(unixepoch(?, 'subsec') * 1000 AS INTEGER),?,?,?)").join(","), + "INSERT OR REPLACE INTO messages VALUES " + messages.map(_ -> "(?,?,?,?,?,?,?,?,CAST(unixepoch(?, 'subsec') * 1000 AS INTEGER),?,?,?,?)").join(","), messages.flatMap(m -> { final correctable = m; final message = m.versions.length == 1 ? m.versions[0] : m; // TODO: storing multiple versions at once? We never do that right now ([ accountId, message.serverId, message.serverIdBy, - message.localId, correctable.localId ?? correctable.serverId, correctable.syncPoint, correctable.chatId(), - message.timestamp, message.status, message.direction, + message.localId, correctable.localId ?? correctable.serverId, correctable.syncPoint, + correctable.chatId(), correctable.senderId, + message.timestamp, message.status, message.direction, message.type, message.asStanza().toString() ] : Array<Dynamic>); }) @@ -318,7 +321,7 @@ class Sqlite implements Persistence implements KeyValueStore { } public function getMessage(accountId: String, chatId: String, serverId: Null<String>, localId: Null<String>, callback: (Null<ChatMessage>)->Void) { - var q = "SELECT stanza, direction, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND chat_id=?"; + var q = "SELECT stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND chat_id=?"; final params = [accountId, chatId]; if (serverId != null) { q += " AND mam_id=?"; @@ -328,9 +331,8 @@ class Sqlite implements Persistence implements KeyValueStore { params.push(localId); } q += "LIMIT 1"; - db.exec(q, params).then(result -> { - for (row in result) { - final message = hydrateMessage(accountId, row); + db.exec(q, params).then(result -> hydrateMessages(accountId, result)).then(messages -> { + for (message in messages) { (if (message.replyToMessage != null) { hydrateReplyTo(accountId, [message], [{ chatId: chatId, serverId: message.replyToMessage.serverId, localId: message.replyToMessage.localId }]); } else { @@ -349,9 +351,12 @@ class Sqlite implements Persistence implements KeyValueStore { json_group_object(COALESCE(versions.mam_id, versions.stanza_id), strftime('%FT%H:%M:%fZ', versions.created_at / 1000.0, 'unixepoch')) AS version_times, json_group_object(COALESCE(versions.mam_id, versions.stanza_id), versions.stanza) AS versions, messages.direction, + messages.type, strftime('%FT%H:%M:%fZ', messages.created_at / 1000.0, 'unixepoch') AS timestamp, + messages.sender_id, messages.mam_id, messages.mam_by, + messages.sync_point, MAX(versions.created_at) FROM messages INNER JOIN messages versions USING (correction_id) WHERE messages.stanza_id=correction_id AND messages.account_id=? AND messages.chat_id=?"; final params = [accountId, chatId]; @@ -364,10 +369,7 @@ class Sqlite implements Persistence implements KeyValueStore { q += ", messages.ROWID"; if (op == "<" || op == "<=") q += " DESC"; q += " LIMIT 50"; - return db.exec(q, params).then(result -> ({ - hasNext: result.hasNext, - next: () -> hydrateMessage(accountId, result.next()) - })).then(iter -> { + return db.exec(q, params).then(result -> hydrateMessages(accountId, result)).then(iter -> { final arr = []; final replyTos = []; for (message in iter) { @@ -425,7 +427,7 @@ class Sqlite implements Persistence implements KeyValueStore { final params: Array<Dynamic> = [accountId]; // subq is first in final q, so subq params first final subq = new StringBuf(); - subq.add("SELECT chat_id, MAX(ROWID) AS row FROM messages WHERE account_id=?"); + subq.add("SELECT chat_id, MAX(created_at) AS created_at FROM messages WHERE account_id=?"); subq.add(" AND chat_id IN ("); for (i => chat in chats) { if (i != 0) subq.add(","); @@ -446,7 +448,7 @@ class Sqlite implements Persistence implements KeyValueStore { params.push(MessageSent); final q = new StringBuf(); - q.add("SELECT chat_id AS chatId, stanza, direction, mam_id, mam_by, CASE WHEN subq.row IS NULL THEN COUNT(*) ELSE COUNT(*) - 1 END AS unreadCount, strftime('%FT%H:%M:%fZ', MAX(messages.created_at) / 1000.0, 'unixepoch') AS timestamp FROM messages LEFT JOIN ("); + q.add("SELECT chat_id AS chatId, stanza, direction, type, sender_id, mam_id, mam_by, sync_point, CASE WHEN subq.created_at IS NULL THEN COUNT(*) ELSE COUNT(*) - 1 END AS unreadCount, strftime('%FT%H:%M:%fZ', MAX(messages.created_at) / 1000.0, 'unixepoch') AS timestamp FROM messages LEFT JOIN ("); q.add(subq.toString()); q.add(") subq USING (chat_id) WHERE account_id=? AND chat_id IN ("); params.push(accountId); @@ -455,17 +457,20 @@ class Sqlite implements Persistence implements KeyValueStore { q.add("?"); params.push(chat.chatId); } - q.add(") AND (subq.row IS NULL OR messages.ROWID >= subq.row) GROUP BY chat_id;"); + q.add(") AND (subq.created_at IS NULL OR messages.created_at >= subq.created_at) GROUP BY chat_id;"); db.exec(q.toString(), params).then(result -> { final details = []; - for (row in result) { - details.push({ - unreadCount: row.unreadCount, - chatId: row.chatId, - message: hydrateMessage(accountId, row) - }); - } - callback(details); + final rows: Array<Dynamic> = { iterator: () -> result }.array(); + Promise.resolve(hydrateMessages(accountId, rows.iterator())).then(messages -> { + for (i => m in messages) { + details.push({ + unreadCount: rows[i].unreadCount, + chatId: rows[i].chatId, + message: m + }); + } + callback(details); + }); }); } @@ -490,12 +495,11 @@ class Sqlite implements Persistence implements KeyValueStore { [status, accountId, localId, MessageSent, MessageDeliveredToDevice] ).then(_ -> db.exec( - "SELECT stanza, strftime('%FT%H:%M:%fZ') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND stanza_id=? AND direction=?", + "SELECT stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND stanza_id=? AND direction=?", [accountId, localId, MessageSent] ) - ).then(result -> { - for (row in result) { - final message = hydrateMessage(accountId, row); + ).then(result -> hydrateMessages(accountId, result)).then(messages -> { + for (message in messages) { (if (message.replyToMessage != null) { hydrateReplyTo(accountId, [message], [{ chatId: message.chatId(), serverId: message.replyToMessage.serverId, localId: message.replyToMessage.localId }]); } else { @@ -668,7 +672,7 @@ class Sqlite implements Persistence implements KeyValueStore { return fetchReactions(accountId, messages.map(m -> ({ chatId: m.chatId(), serverId: m.serverId, serverIdBy: m.serverIdBy, localId: m.localId }))).then(result -> { for (id => reactions in result) { final m = messages.find(m -> ((m.serverId == null ? m.localId : m.serverId + "\n" + m.serverIdBy) + "\n" + m.chatId()) == id); - if (m != null) m.reactions = reactions; + if (m != null) m.set_reactions(reactions); } return messages; }); @@ -732,7 +736,7 @@ class Sqlite implements Persistence implements KeyValueStore { } else { final params = [accountId]; final q = new StringBuf(); - q.add("SELECT chat_id, stanza_id, stanza, direction, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, mam_id, mam_by FROM messages WHERE account_id=? AND ("); + q.add("SELECT chat_id, stanza_id, stanza, direction, type, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sync_point FROM messages WHERE account_id=? AND ("); q.add(replyTos.map(parent -> if (parent.serverId != null) { params.push(parent.chatId); @@ -752,7 +756,7 @@ class Sqlite implements Persistence implements KeyValueStore { for (message in messages) { if (message.replyToMessage != null) { final found: Dynamic = parents.find(p -> p.chat_id == message.chatId() && (message.replyToMessage.serverId == null || p.mam_id == message.replyToMessage.serverId) && (message.replyToMessage.localId == null || p.stanza_id == message.replyToMessage.localId)); - if (found != null) message.replyToMessage = hydrateMessage(accountId, found); + if (found != null) message.set_replyToMessage(hydrateMessages(accountId, [found].iterator())[0]); } } } @@ -760,31 +764,36 @@ class Sqlite implements Persistence implements KeyValueStore { }); } - private function hydrateMessage(accountId: String, row: { stanza: String, timestamp: String, direction: MessageDirection, mam_id: String, mam_by: String, ?stanza_id: String, ?versions: String, ?version_times: String }) { - // TODO + private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, mam_id: String, mam_by: String, sync_point: Bool, sender_id: String, ?stanza_id: String, ?versions: String, ?version_times: String }>): Array<ChatMessage> { // TODO: Calls can "edit" from multiple senders, but the original direction and sender holds final accountJid = JID.parse(accountId); - final x = ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid); - x.timestamp = row.timestamp; - x.direction = row.direction; - x.serverId = row.mam_id; - x.serverIdBy = row.mam_by; - if (row.stanza_id != null) Reflect.setField(x, "localId", row.stanza_id); - if (row.versions != null) { - final versionTimes: DynamicAccess<String> = Json.parse(row.version_times); - final versions: DynamicAccess<String> = Json.parse(row.versions); - if (versions.keys().length > 1) { - for (version in versions) { - final versionM = ChatMessage.fromStanza(Stanza.parse(version), accountJid); - final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0]; - if (toPush != null) { - toPush.timestamp = versionTimes[toPush.serverId ?? toPush.localId]; - x.versions.push(toPush); + return { iterator: () -> rows }.map(row -> ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid, (builder, _) -> { + builder.syncPoint = row.sync_point; + builder.timestamp = row.timestamp; + builder.direction = row.direction; + builder.type = row.type; + builder.senderId = row.sender_id; + builder.serverId = row.mam_id; + builder.serverIdBy = row.mam_by; + if (row.stanza_id != null) builder.localId = row.stanza_id; + if (row.versions != null) { + final versionTimes: DynamicAccess<String> = Json.parse(row.version_times); + final versions: DynamicAccess<String> = Json.parse(row.versions); + if (versions.keys().length > 1) { + for (version in versions) { + final versionM = ChatMessage.fromStanza(Stanza.parse(version), accountJid, (toPushB, _) -> { + toPushB.timestamp = versionTimes[toPushB.serverId ?? toPushB.localId]; + return toPushB; + }); + final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0]; + if (toPush != null) { + builder.versions.push(toPush); + } } + builder.versions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp)); } - x.versions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp)); } - } - return x; + return builder; + })); } }