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>
186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
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", " |