import "fake-indexeddb/auto"; import { test } from "node:test"; import assert from "node:assert/strict"; import parse from "ltx/lib/parse.js"; import { ChatDB, type StoredMessage } from "../src/store/db.ts"; import { parseMessage } from "../src/xmpp/parse.ts"; import { buildReactions, canReact, groupReactions, isSingleEmoji, MAX_PER_SENDER, normalizeSet, parseReactionRow, parseReactions, reactionKey, reactionLeaks, reactionTarget, REACTIONS_NS, toggle, } from "../src/xmpp/reactions.ts"; const me = "me@example.org", peer = "you@example.org"; const room = "room@conference.example.org"; const el = (s: string) => parse(s) as any; const message = (over: Partial = {}): StoredMessage => ({ key: `${peer}|${peer}|origin-id`, peer, type: "chat", dir: "in", from: peer + "/phone", id: "sender-id", originId: "origin-id", body: "Hello", ts: 10, ...over, }); test("a reaction is one emoji, never text, a letter or a pair", () => { for (const good of ["๐Ÿ‘", "โค๏ธ", "๐Ÿ™๐Ÿฝ", "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", "๐Ÿ‡ฉ๐Ÿ‡ช", "โ˜บ"]) { assert.equal(isSingleEmoji(good), true, good); } for (const bad of ["", " ", "a", "7", "๐Ÿ‘๐Ÿ‘", "๐Ÿ‘ ", "ok", "๐Ÿ‘x", "", "๐Ÿ‘\n"]) { assert.equal(isSingleEmoji(bad), false, JSON.stringify(bad)); } // A whole keyboard's worth is clamped, and duplicates collapse. const many = normalizeSet(["๐Ÿ‘", "๐Ÿ‘", "โค๏ธ", ...Array.from({ length: 30 }, () => "๐Ÿ˜ฎ")]); assert.deepEqual(many, ["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜ฎ"]); assert.equal(normalizeSet(Array.from({ length: 40 }, (_, i) => String.fromCodePoint(0x1f600 + i))).length, MAX_PER_SENDER); assert.deepEqual(normalizeSet(["nope", "๐Ÿ‘", "also nope"]), ["๐Ÿ‘"]); }); test("reactions address the same id a reply would, and refuse what has none", () => { assert.equal(reactionTarget(message()), "origin-id"); assert.equal(canReact(message()), true); // Encrypted is allowed and flagged as leaking; undecryptable is refused. assert.equal(reactionLeaks(message({ encrypted: true })), true); assert.equal(reactionLeaks(message()), false); assert.equal(canReact(message({ encrypted: true, undecryptable: true })), false); // A room message without a room-issued stanza id has no reactable id, // exactly as it has no replyable one. const group = (extra: string) => parseMessage(el(`hi${extra}`), { me, archiveId: "mam-cursor" })!; assert.equal(reactionTarget(group("")), undefined); assert.equal(canReact(group("")), false); assert.equal(reactionTarget(group(``)), "room-id"); }); test("the wire carries full state plus the archive hint, and withdrawal is an empty set", () => { const elements = buildReactions("origin-id", ["๐Ÿ‘", "โค๏ธ"]); const stanza = el(`${elements.join("")}`); // Without mod_mam would not archive a bodiless stanza at all // (REACTIONS-PLAN.md ยง3), so its absence is a silent data-loss bug. assert.ok(stanza.getChild("store", "urn:xmpp:hints")); assert.deepEqual(parseReactions(stanza), { id: "origin-id", emoji: ["๐Ÿ‘", "โค๏ธ"] }); const withdrawn = el(`${buildReactions("origin-id", []).join("")}`); assert.deepEqual(parseReactions(withdrawn), { id: "origin-id", emoji: [] }); assert.deepEqual(toggle(["๐Ÿ‘", "โค๏ธ"], "๐Ÿ‘"), ["โค๏ธ"]); assert.deepEqual(toggle(["๐Ÿ‘"], "โค๏ธ"), ["๐Ÿ‘", "โค๏ธ"]); assert.deepEqual(toggle([], "๐Ÿ‘"), ["๐Ÿ‘"]); }); test("hostile reaction payloads are refused or clamped, never rendered", () => { const wrap = (inner: string) => el(`${inner}`); assert.equal(parseReactions(wrap("hi")), undefined); assert.equal(parseReactions(wrap(`๐Ÿ‘`)), undefined); assert.equal(parseReactions(wrap(`๐Ÿ‘`)), undefined); assert.equal(parseReactions(wrap(``)), undefined); // Twenty emoji, and arbitrary text where an emoji belongs. const flood = `${Array.from({ length: 20 }, (_, i) => `${String.fromCodePoint(0x1f600 + i)}`).join("")}drop database`; assert.equal(parseReactions(wrap(flood))!.emoji.length, MAX_PER_SENDER); assert.equal(parseReactions(wrap(flood))!.emoji.includes("drop database"), false); // An error bounces our own payload back; that is not its sender reacting. const bounced = el(`๐Ÿ‘`); assert.equal(parseReactionRow(bounced, { me }), null); // A room reaction has to come from an occupant, not the bare room JID. const bareRoom = el(`๐Ÿ‘`); assert.equal(parseReactionRow(bareRoom, { me, isRoom: (j) => j === room }), null); }); test("an incoming reaction lands in the right conversation under the right sender", () => { const incoming = el(`๐Ÿ‘`); const row = parseReactionRow(incoming, { me })!; assert.equal(row.peer, peer); assert.equal(row.sender, peer); assert.equal(row.key, reactionKey(peer, "origin-id", peer)); // Our own, as a sent carbon from another device: the conversation is the // contact, the sender is us. const own = el(`๐Ÿ‘`); const mine = parseReactionRow(own, { me })!; assert.equal(mine.peer, peer); assert.equal(mine.sender, me); // In a room the occupant JID is the identity, as it is for messages. const inRoom = el(`โค๏ธ`); const groupRow = parseReactionRow(inRoom, { me, nick: "Me", isRoom: (j) => j === room })!; assert.equal(groupRow.peer, room); assert.equal(groupRow.sender, `${room}/Nick`); // The archive's delay wins over "now", so a replayed reaction keeps its age. const forwarded = el(``); assert.equal(parseReactionRow(incoming, { me, forwarded })!.ts, Date.parse("2026-10-08T12:00:00Z")); }); test("the store keeps full state per sender, tolerates replays and reactions without their message", async () => { const db = await ChatDB.open("reactions-store@example.org"); const row = (sender: string, emoji: string[], ts: number) => ({ key: reactionKey(peer, "origin-id", sender), peer, targetId: "origin-id", sender, emoji, ts, }); // A reaction may arrive before the message it points at (MAM pages // backwards) and must wait for it rather than be dropped. assert.equal(await db.putReactions(row(peer, ["๐Ÿ‘"], 100)), true); assert.equal((await db.reactionsFor(peer, ["origin-id"])).get("origin-id")!.length, 1); assert.equal((await db.getMessages(peer)).length, 0); // Two senders, then one of them changes their whole set: full-state // overwrite, not a union. await db.putReactions(row(me, ["๐Ÿ‘", "โค๏ธ"], 110)); await db.putReactions(row(me, ["โค๏ธ"], 120)); const stored = (await db.reactionsFor(peer, ["origin-id"])).get("origin-id")!; assert.deepEqual(stored.find((r) => r.sender === me)!.emoji, ["โค๏ธ"]); assert.deepEqual(stored.find((r) => r.sender === peer)!.emoji, ["๐Ÿ‘"]); // An older copy of the same row (live stanza, then its MAM replay) never // resurrects a withdrawn reaction. assert.equal(await db.putReactions(row(me, ["๐Ÿ‘", "โค๏ธ"], 110)), false); assert.deepEqual((await db.reactionsFor(peer, ["origin-id"])).get("origin-id")!.find((r) => r.sender === me)!.emoji, ["โค๏ธ"]); // The same set again is not a change, so the UI is not told to re-render. assert.equal(await db.putReactions(row(me, ["โค๏ธ"], 130)), false); // The empty set deletes the row; deleting a row that is not there is not // a change either. assert.equal(await db.putReactions(row(me, [], 140)), true); assert.equal(await db.putReactions(row(me, [], 150)), false); const left = (await db.reactionsFor(peer, ["origin-id"])).get("origin-id")!; assert.deepEqual(left.map((r) => r.sender), [peer]); // Reactions are per conversation: another chat's identical target id is // a different row, and an unknown target reads back empty. await db.putReactions({ ...row(peer, ["๐Ÿ˜ฎ"], 160), key: reactionKey("third@example.org", "origin-id", peer), peer: "third@example.org" }); assert.equal((await db.reactionsFor(peer, ["origin-id"])).get("origin-id")!.length, 1); assert.equal((await db.reactionsFor(peer, ["no-such-id"])).size, 0); assert.equal((await db.reactionsFor(peer, [])).size, 0); db.close(); }); test("display groups by emoji with counts and marks our own, whatever resource it came from", () => { const rows = [ { sender: peer, emoji: ["๐Ÿ‘", "โค๏ธ"], ts: 10 }, { sender: `${me}/web`, emoji: ["๐Ÿ‘"], ts: 20 }, { sender: "third@example.org", emoji: ["โค๏ธ"], ts: 5 }, ]; const groups = groupReactions(rows, me); // Ordered by when each emoji was first given: โค๏ธ at 5, then ๐Ÿ‘ at 10. assert.deepEqual(groups.map((g) => g.emoji), ["โค๏ธ", "๐Ÿ‘"]); const thumbs = groups.find((g) => g.emoji === "๐Ÿ‘")!; assert.equal(thumbs.count, 2); assert.equal(thumbs.mine, true); assert.deepEqual(groups.find((g) => g.emoji === "โค๏ธ")!.senders, ["third@example.org", peer]); assert.equal(groups.find((g) => g.emoji === "โค๏ธ")!.mine, false); assert.deepEqual(groupReactions([], me), []); // In a room the identity is the occupant JID, not the account: passing the // account would leave our own reaction unmarked, which is what the // groupchat runner check caught. const occupant = `${room}/Me`; const inRoom = [{ sender: occupant, emoji: ["๐Ÿ‘"], ts: 10 }, { sender: `${room}/Other`, emoji: ["๐Ÿ˜ข"], ts: 20 }]; assert.equal(groupReactions(inRoom, occupant).find((g) => g.emoji === "๐Ÿ‘")!.mine, true); assert.equal(groupReactions(inRoom, occupant).find((g) => g.emoji === "๐Ÿ˜ข")!.mine, false); assert.equal(groupReactions(inRoom, me).some((g) => g.mine), false); });