monal-rebuild/test/reactions.test.ts
pijukka 66a374ef1e feat(chat): emoji reactions (XEP-0444)
Give, withdraw, receive and group reactions by emoji, in 1:1 chats and in
rooms, surviving a reload out of the server's archive.

Addressing is the question quoted replies already answered, so `replyId()`
owns it for both: the origin id in 1:1, the room-issued stanza id in a
room. The set is full state, never a delta. Reactions live in their own
store keyed by the reacted-to message's stable id, because one can arrive
before the message it points at; an older copy never replaces a newer one.
`parseMessage` discards bodiless stanzas, so the receive path parses them
separately, and a reaction never touches a conversation's unread count or
preview: it is not a message. `mod_mam` has no reactions branch, so the
`<store/>` hint is what makes them survive at all.

Reactions go out in cleartext even in an encrypted chat, and the picker
says so where the reaction is given. The client has no XEP-0420 envelope,
so nothing can ride inside the ciphertext; this is a named exception and
the fail-closed rule stays in force for everything carrying content.
Decided by the operator, REACTIONS-PLAN.md §4.

Monal 6.4.21 has no XEP-0444 and drops a bodiless message before it can
become a bubble or a notification, so this is 0ff chat to 0ff chat for now
and costs the phone nothing. Read from its source, §5.

Checks: zero svelte-check errors, 93 tests, production build, and
ux-reactions-runner.cjs 11/11 with zero page errors under both Chromium
and WebKit against the built preview and the real server — including the
account's own MAM archive and the room's. Evidence and the two
own-reaction bugs the room checks found: UX-REACTIONS-RESULTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:58:03 +02:00

186 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> = {}): 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(`<message type="groupchat" from="${room}/Nick" id="sender"><body>hi</body>${extra}</message>`), { me, archiveId: "mam-cursor" })!;
assert.equal(reactionTarget(group("")), undefined);
assert.equal(canReact(group("")), false);
assert.equal(reactionTarget(group(`<stanza-id xmlns="urn:xmpp:sid:0" by="${room}" id="room-id"/>`)), "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(`<message to="${peer}" type="chat" id="r1">${elements.join("")}</message>`);
// Without <store/> 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(`<message to="${peer}" type="chat" id="r2">${buildReactions("origin-id", []).join("")}</message>`);
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(`<message from="${peer}/phone" to="${me}" type="chat">${inner}</message>`);
assert.equal(parseReactions(wrap("<body>hi</body>")), undefined);
assert.equal(parseReactions(wrap(`<reactions xmlns="urn:xmpp:other" id="x"><reaction>👍</reaction></reactions>`)), undefined);
assert.equal(parseReactions(wrap(`<reactions xmlns="${REACTIONS_NS}"><reaction>👍</reaction></reactions>`)), undefined);
assert.equal(parseReactions(wrap(`<reactions xmlns="${REACTIONS_NS}" id="${"x".repeat(2000)}"/>`)), undefined);
// Twenty emoji, and arbitrary text where an emoji belongs.
const flood = `<reactions xmlns="${REACTIONS_NS}" id="origin-id">${Array.from({ length: 20 }, (_, i) => `<reaction>${String.fromCodePoint(0x1f600 + i)}</reaction>`).join("")}<reaction>drop database</reaction></reactions>`;
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(`<message from="${peer}" to="${me}" type="error"><reactions xmlns="${REACTIONS_NS}" id="origin-id"><reaction>👍</reaction></reactions></message>`);
assert.equal(parseReactionRow(bounced, { me }), null);
// A room reaction has to come from an occupant, not the bare room JID.
const bareRoom = el(`<message from="${room}" type="groupchat"><reactions xmlns="${REACTIONS_NS}" id="room-id"><reaction>👍</reaction></reactions></message>`);
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(`<message from="${peer}/phone" to="${me}" type="chat" id="r"><reactions xmlns="${REACTIONS_NS}" id="origin-id"><reaction>👍</reaction></reactions></message>`);
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(`<message from="${me}/other" to="${peer}" type="chat" id="r"><reactions xmlns="${REACTIONS_NS}" id="origin-id"><reaction>👍</reaction></reactions></message>`);
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(`<message from="${room}/Nick" type="groupchat" id="r"><reactions xmlns="${REACTIONS_NS}" id="room-id"><reaction>❤️</reaction></reactions></message>`);
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(`<forwarded xmlns="urn:xmpp:forward:0"><delay xmlns="urn:xmpp:delay" stamp="2026-10-08T12:00:00Z"/></forwarded>`);
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);
});