| author | Eric Roberts
<eric@devl.me> 2026-09-15 15:59:33 UTC |
| committer | Eric Roberts
<eric@devl.me> 2026-09-21 17:49:22 UTC |
| parent | d293727d56fac8c32227e819cfa53918e5b67756 |
| borogove/ChatMessage.hx | +4 | -0 |
| borogove/ChatMessageCombiner.hx | +80 | -0 |
| borogove/persistence/IDB.js | +5 | -2 |
| borogove/persistence/Sqlite.hx | +7 | -3 |
| test/TestAll.hx | +1 | -0 |
| test/TestChatMessageCombiner.hx | +117 | -0 |
| test/persistence-tests.ts | +86 | -0 |
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index 3e9bde4..22ad453 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -519,6 +519,10 @@ class ChatMessage { ((!isIncoming() && !other.isIncoming()) || (senderId == other.senderId && versions.length > 0))); } + public static function combineCorrections(messages: Array<ChatMessage>): Array<ChatMessage> { + return ChatMessageCombiner.combine(messages); + } + /** Is this an incoming message? **/ diff --git a/borogove/ChatMessageCombiner.hx b/borogove/ChatMessageCombiner.hx new file mode 100644 index 0000000..162376e --- /dev/null +++ b/borogove/ChatMessageCombiner.hx @@ -0,0 +1,80 @@ +package borogove; + +using Lambda; + +@:expose +class ChatMessageCombiner { + public static function combine(messages: Array<ChatMessage>): Array<ChatMessage> { + return messages + .fold( + (message, grouped:Map<String, Array<ChatMessage>>) -> { + final key = correctionKey(message); + final messages = grouped[key] ?? []; + grouped.set(key, messages.concat([message])); + grouped; + }, + new Map<String, Array<ChatMessage>>() + ) + .map(messages -> combineMessages(messages)); + } + + private static function correctionKey(message: ChatMessage): String { + if (message.localId == null) return ID.unique(); + + final sender = message.type == MessageCall ? "call" : message.senderId; + return message.localId + "\n" + message.chatId() + "\n" + sender; + } + + private static function combineMessages(messages: Array<ChatMessage>): ChatMessage { + final original = messages.find(message -> message.versions.length == 0) ?? messages[0]; + final combinedVersions = combineVersions(original, messages); + final newest = combinedVersions[0]; + + final builder = ChatMessageBuilder.fromMessage(newest); + builder.localId = original.localId; + builder.serverId = original.serverId; + builder.serverIdBy = original.serverIdBy; + builder.sortId = original.sortId; + builder.timestamp = original.timestamp; + builder.replyId = original.replyId; + builder.reactions = original.reactions; + if (original.type == MessageCall) { + builder.direction = original.direction; + builder.senderId = original.senderId; + builder.from = original.from; + builder.to = original.to; + builder.replyTo = original.replyTo.array(); + builder.recipients = original.recipients.array(); + } + builder.versions = combinedVersions; + return builder.build(); + } + + private static function combineVersions( + original: ChatMessage, + messages: Array<ChatMessage>, + ): Array<ChatMessage> { + final versions = messages.flatMap(message -> message.versions.array()); + if (original.versions.length == 0) versions.push(original); + + final sortedVersions = versions.fold( + (version, uniqueVersions:Map<String, ChatMessage>) -> { + final id = identity(version); + final previous = uniqueVersions[id]; + if (previous == null || version.timestamp > previous.timestamp) { + uniqueVersions.set(id, version); + } + uniqueVersions; + }, + new Map<String, ChatMessage>() + ).map(version -> version); + sortedVersions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp)); + return sortedVersions; + } + + private static function identity(message: ChatMessage): String { + return message.serverId == null + ? "local:" + message.localId + : "server:" + message.serverId + "\n" + message.serverIdBy; + } +} diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 3f45d7e..5a53b83 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -8,6 +8,7 @@ import { borogove_Channel, borogove_ChatAttachment, borogove_ChatMessageBuilder, + borogove_ChatMessageCombiner, borogove_CustomEmojiReaction, borogove_DirectChat, borogove_Hash, @@ -1221,12 +1222,14 @@ export default async (dbname, media, tokenize, stemmer) => { await Promise.all(m.attachments.map((a) => a.lookup(this))); } + const combinedMessages = + await borogove_ChatMessageCombiner.combine(messages); const tx = db.transaction(["messages", "reactions"], "readwrite"); const store = tx.objectStore("messages"); const promises = []; tx.onerror = console.error; - for (const [index, m] of messages.entries()) { - const isLast = index + 1 >= messages.length; + for (const [index, m] of combinedMessages.entries()) { + const isLast = index + 1 >= combinedMessages.length; promises.push(this.storeMessage(tx, store, account, m, isLast)); } diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 8120bc3..ab3b3f3 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -834,9 +834,13 @@ class Sqlite implements Persistence implements KeyValueStore { return storeMessagesSerialized.run(() -> // Hmm, if there is an existing one this loses the original timestamp though - insertMessages(accountId, messages).then(_ -> - thenshim.PromiseTools.all(messages.map(m -> fetchFromStub(accountId, m))) - ).then(ms -> + insertMessages(accountId, messages).then(_ -> { + // Sqlite will store originals and their corrections properly, but + // here we just map the messages we received, which means we'll return + // both unless we combine them. + final combinedMessages = ChatMessageCombiner.combine(messages); + thenshim.PromiseTools.all(combinedMessages.map(m -> fetchFromStub(accountId, m))); + }).then(ms -> thenshim.PromiseTools.all(ms.flatMap(m -> m.attachments.map(a -> a.lookup(this)))).then(_ -> hydrateReplyTo(accountId, ms, replyTos) ) diff --git a/test/TestAll.hx b/test/TestAll.hx index 73384e9..707b619 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -17,6 +17,7 @@ class TestAll { new TestCapsRepo(), new TestChat(), new TestChatMessage(), + new TestChatMessageCombiner(), new TestChatMessageBuilder(), new TestClient(), new TestEmojiUtil(), diff --git a/test/TestChatMessageCombiner.hx b/test/TestChatMessageCombiner.hx new file mode 100644 index 0000000..149b170 --- /dev/null +++ b/test/TestChatMessageCombiner.hx @@ -0,0 +1,117 @@ +package test; + +import utest.Assert; +import borogove.ChatMessage; +import borogove.ChatMessageCombiner; +import borogove.JID; +import borogove.Message; + +@:access(borogove) +class TestChatMessageCombiner extends utest.Test { + public function testCombineCorrections() { + final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z", sortId: "m", body: "old" }); + final correction = correction(original, "version", "2024-01-02T00:00:00Z", "new"); + final combined = ChatMessageCombiner.combine([original, correction]); + + Assert.equals(1, combined.length); + Assert.equals("original", combined[0].localId); + Assert.equals("m", combined[0].sortId); + Assert.equals("new", combined[0].body().toPlainText()); + Assert.equals(2, combined[0].versions.length); + Assert.equals("version", combined[0].versions[0].localId); + Assert.equals("original", combined[0].versions[1].localId); + } + + public function testCombineCorrectionsKeepsUnrelatedMessages() { + final first = message({ localId: "first", timestamp: "2024-01-01T00:00:00Z" }); + final original = message({ localId: "original", timestamp: "2024-01-02T00:00:00Z" }); + final other = message({ localId: "other", chatId: "carol@example.com" }); + final correction = correction(original, "version", "2024-01-03T00:00:00Z", "new"); + final combined = ChatMessageCombiner.combine([first, correction, other, original]); + + final localIds = combined.map(m -> m.localId); + localIds.sort(Reflect.compare); + Assert.equals(3, combined.length); + Assert.same(["first", "original", "other"], localIds); + } + + public function testCombineCorrectionsSupportsVersionedHeadsAndDeduplicates() { + final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z" }); + final first = correction(original, "v1", "2024-01-02T00:00:00Z", "first"); + final second = correction(original, "v2", "2024-01-03T00:00:00Z", "second"); + final combined = ChatMessageCombiner.combine([first, original, second, second]); + + Assert.equals(1, combined.length); + Assert.equals("second", combined[0].body().toPlainText()); + Assert.equals(3, combined[0].versions.length); + Assert.equals("v2", combined[0].versions[0].localId); + Assert.equals("v1", combined[0].versions[1].localId); + Assert.equals("original", combined[0].versions[2].localId); + } + + public function testCombineCorrectionsWithoutOriginal() { + final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z", sortId: "m" }); + final first = correction(original, "v1", "2024-01-02T00:00:00Z", "first"); + final second = correction(original, "v2", "2024-01-03T00:00:00Z", "second"); + final third = correction(original, "v3", "2024-01-04T00:00:00Z", "third"); + final combined = ChatMessageCombiner.combine([third, first, second]); + + Assert.equals(1, combined.length); + Assert.equals("original", combined[0].localId); + Assert.equals("third", combined[0].body().toPlainText()); + Assert.equals(3, combined[0].versions.length); + Assert.equals("v3", combined[0].versions[0].localId); + Assert.equals("v2", combined[0].versions[1].localId); + Assert.equals("v1", combined[0].versions[2].localId); + } + + public function testCombineCorrectionsCallSenderMismatch() { + final original = message({ localId: "call", senderId: "alice@example.com", type: MessageCall }); + final callCorrection = correction(original, "call-version", "2024-01-02T00:00:00Z", "updated", "bob@example.com"); + final textOriginal = message({ localId: "text", senderId: "alice@example.com" }); + final textCorrection = correction(textOriginal, "text-version", "2024-01-02T00:00:00Z", "updated", "bob@example.com"); + + Assert.equals(3, ChatMessageCombiner.combine([original, callCorrection, textOriginal, textCorrection]).length); + } + + public function testCombineCorrectionsDoesNotMutateInputs() { + final original = message({ localId: "original" }); + final correctionM = correction(original, "version", "2024-01-02T00:00:00Z", "new"); + final input = [original, correctionM]; + final originalVersions = original.versions.length; + ChatMessageCombiner.combine(input); + + Assert.equals(2, input.length); + Assert.equals(originalVersions, original.versions.length); + Assert.equals(1, correctionM.versions.length); + } + + private function message(params: { + ?localId: Null<String>, ?chatId: String, ?senderId: String, ?type: MessageType, + ?versions: Array<ChatMessage>, ?timestamp: String, ?body: String, ?sortId: String + }): ChatMessage { + final builder = new borogove.ChatMessageBuilder(); + builder.localId = params.localId; + builder.from = JID.parse(params.senderId ?? "alice@example.com"); + builder.to = JID.parse(params.chatId ?? "bob@example.com"); + builder.recipients = [builder.to]; + builder.senderId = params.senderId ?? "alice@example.com"; + builder.type = params.type ?? MessageChat; + builder.versions = params.versions ?? []; + builder.timestamp = params.timestamp; + builder.sortId = params.sortId; + builder.setBody(borogove.Html.text(params.body ?? "message")); + return builder.build(); + } + + private function correction(original: ChatMessage, versionId: String, timestamp: String, body: String, ?senderId: String): ChatMessage { + final originalId = original.localId ?? throw "Correction requires an original local ID"; + final chatId = original.chatId(); + final correctionSenderId = senderId ?? original.senderId; + return message({ + localId: originalId, chatId: chatId, senderId: correctionSenderId, type: original.type, + timestamp: timestamp, body: body, + versions: [message({ localId: versionId, chatId: chatId, senderId: correctionSenderId, type: original.type, timestamp: timestamp, body: body })] + }); + } +} diff --git a/test/persistence-tests.ts b/test/persistence-tests.ts index beee6c8..6131a0c 100644 --- a/test/persistence-tests.ts +++ b/test/persistence-tests.ts @@ -1977,6 +1977,92 @@ export function sharedPersistenceTests(test) { original.text, ]); }); + + test("storeMessages properly stores two corrections in the same batch when original is already stored", async ({ + factories, + borogove, + persistence, + }) => { + const account = faker.internet.email(); + const to = borogove.JID.parse(account); + const original = factories.message({ to, text: "Original message" }); + await persistence.storeMessages(account, [original]); + + const corrections = factories + .corrections(original, [ + { text: "First correction" }, + { text: "Second correction" }, + ]) + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + const [stored, ...restStored] = await persistence.storeMessages( + account, + corrections, + ); + + expect(restStored.length).toBe(0); + expect(stored.localId).toBe(original.localId); + expect(stored.serverId).toBe(original.serverId); + expect(stored.text).toBe(corrections[0].text); + expect(stored.versions.map((version) => version.text)).toEqual([ + ...corrections.map((c) => c.text), + original.text, + ]); + + const [fetched, ...restFetched] = await persistence.getMessagesBefore( + account, + original.chatId(), + ); + + expect(restFetched.length).toBe(0); + expect(fetched?.localId).toBe(original.localId); + expect(fetched?.serverId).toBe(original.serverId); + expect(fetched?.text).toBe(corrections[0].text); + expect(fetched?.versions.map((version) => version.text)).toEqual([ + ...corrections.map((c) => c.text), + original.text, + ]); + }); + + test("storeMessages properly stores corrected messages when original in same batch", async ({ + factories, + borogove, + persistence, + }) => { + const account = faker.internet.email(); + const to = borogove.JID.parse(account); + const original = factories.message({ to, text: "Original message" }); + + const [correction] = factories.corrections(original, [ + { text: "Corrected message" }, + ]); + const [storedCorrected, ...restStored] = await persistence.storeMessages( + account, + [original, correction], + ); + + expect(restStored.length).toBe(0); + expect(storedCorrected.localId).toBe(original.localId); + expect(storedCorrected.serverId).toBe(original.serverId); + expect(storedCorrected.text).toBe(correction.text); + + expect(storedCorrected.versions.map((version) => version.text)).toEqual([ + correction.text, + original.text, + ]); + + const [fetched, ...restFetched] = await persistence.getMessagesBefore( + account, + original.chatId(), + ); + + expect(restFetched.length).toBe(0); + expect(fetched?.text).toBe(correction.text); + expect(fetched?.versions.map((version) => version.text)).toEqual([ + correction.text, + original.text, + ]); + }); } type TestKeyPair = {