import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { describe, expect, it, vi } from "vitest"; import { react, unreact, type ReactionRecord } from "#lib/api/reaction.ts"; import type { RecordView } from "#lib/api/records.ts"; import { atUriOf, cidOf } from "#lib/api/syntax.ts"; import { applyHeldReactions, buildReactions, holdReactions, pruneHeldReactions, toggleReaction, type ReactionGroup } from "#lib/components/reaction/reactions.ts"; vi.mock("#lib/api/reaction.ts", () => ({ react: vi.fn(), unreact: vi.fn() })); const reaction = (rkey: string, editor: string, kind = "👍"): RecordView => ({ uri: `at://did:plc:barnacle/org.tangled.feed.reaction/${rkey}`, cid: `bafy${rkey}`, value: { $type: "org.tangled.feed.reaction", subject: { uri: "at://did:plc:barnacle/org.tangled.track.ticket/3lk", cid: "bafyticket" }, reaction: kind, createdAt: "2026-01-01T00:00:00Z", "x-tngl-editor": editor } as ReactionRecord }); describe("buildReactions on knot-hosted reactions", () => { it("counts each writer once and finds the viewer's own reaction", () => { const [group] = buildReactions( [reaction("3aa", "did:plc:limpet"), reaction("3ab", "did:plc:mussel")], "did:plc:mussel" ); expect(group.users.map((user) => user.did)).toEqual(["did:plc:limpet", "did:plc:mussel"]); expect(group.isReacted).toBe(true); expect(group.viewerReaction).toEqual({ uri: "at://did:plc:barnacle/org.tangled.feed.reaction/3ab", cid: "bafy3ab" }); }); }); describe("toggleReaction", () => { const agent = {} as OAuthUserAgent; const viewer = { did: "did:plc:mussel", handle: "mussel.test" }; const subject = { uri: "at://did:plc:barnacle/org.tangled.track.ticket/3lk", cid: "bafyreibfko5xzzdptcimcaql4tmsaffgr6jhreyjz3tv6nlh34si6k4u7e" }; const written = { uri: atUriOf("at://did:plc:mussel/org.tangled.feed.reaction/3new"), cid: cidOf("bafyreibfko5xzzdptcimcaql4tmsaffgr6jhreyjz3tv6nlh34si6k4u7e") }; const others: ReactionGroup = { kind: "👍", count: 1, users: [{ did: "did:plc:limpet", handle: "limpet.test" }], isReacted: false }; const mine = { uri: "at://did:plc:mussel/org.tangled.feed.reaction/3old", cid: "bafyold" }; const reacted: ReactionGroup = { kind: "👍", count: 2, users: [...others.users, viewer], isReacted: true, viewerReaction: mine }; const run = (kind: ReactionGroup["kind"], start: ReactionGroup[]) => { let groups = start; const done = toggleReaction({ agent, subject, kind, viewer, bobbin: null, groups: () => groups, commit: (next) => (groups = next) }); return { done, groups: () => groups }; }; it("shows a new reaction before the write settles and keeps the written record", async () => { let settle!: (value: typeof written) => void; vi.mocked(react).mockReturnValueOnce(new Promise((resolve) => (settle = resolve))); const flow = run("👍", [others]); expect(flow.groups()[0]).toMatchObject({ count: 2, isReacted: true }); settle(written); await flow.done; expect(flow.groups()[0].viewerReaction).toEqual(written); }); it("takes a reaction back before the write settles", async () => { let settle!: () => void; vi.mocked(unreact).mockReturnValueOnce(new Promise((resolve) => (settle = resolve))); const flow = run("👍", [reacted]); expect(flow.groups()[0]).toMatchObject({ count: 1, isReacted: false }); settle(); await flow.done; expect(flow.groups()).toHaveLength(1); }); it("drops a kind nobody else used once the viewer takes it back", async () => { vi.mocked(unreact).mockResolvedValueOnce(); const flow = run("👍", [{ ...reacted, count: 1, users: [viewer] }]); await flow.done; expect(flow.groups()).toEqual([]); }); it("puts things back when adding fails", async () => { vi.mocked(react).mockRejectedValueOnce(new Error("offline")); const flow = run("👍", [others]); await expect(flow.done).rejects.toThrow("offline"); expect(flow.groups()).toEqual([others]); }); it("puts things back when taking back fails", async () => { vi.mocked(unreact).mockRejectedValueOnce(new Error("offline")); const flow = run("👍", [reacted]); await expect(flow.done).rejects.toThrow("offline"); expect(flow.groups()).toEqual([reacted]); }); }); describe("keeping the viewer's reactions over an older loaded list", () => { const viewer = { did: "did:plc:mussel", handle: "mussel.test" }; const mine = { uri: "at://did:plc:mussel/org.tangled.feed.reaction/3new", cid: "bafynew" }; const group = (kind: ReactionGroup["kind"], reactedBy: boolean): ReactionGroup => ({ kind, count: reactedBy ? 2 : 1, users: [{ did: "did:plc:limpet", handle: "limpet.test" }, ...(reactedBy ? [viewer] : [])], isReacted: reactedBy, viewerReaction: reactedBy ? mine : undefined }); it("re-applies a reaction the loaded list has not seen yet", () => { const loaded = [group("👍", false)]; const held = holdReactions({}, loaded, [group("👍", true)]); const shown = applyHeldReactions(loaded, held, viewer); expect(shown[0]).toMatchObject({ count: 2, isReacted: true, viewerReaction: mine }); }); it("re-applies a reaction on a kind the loaded list does not have", () => { const held = holdReactions({}, [], [group("🎉", true)]); const shown = applyHeldReactions([group("👍", false)], held, viewer); expect(shown.map((g) => g.kind)).toEqual(["👍", "🎉"]); }); it("keeps a reaction taken back while the loaded list still has it", () => { const loaded = [group("👍", true)]; const held = holdReactions({}, loaded, [group("👍", false)]); const shown = applyHeldReactions(loaded, held, viewer); expect(shown[0]).toMatchObject({ count: 1, isReacted: false }); }); it("drops a kind once the loaded list agrees about it", () => { const held = holdReactions({}, [group("👍", false)], [group("👍", true)]); expect(pruneHeldReactions([group("👍", true)], held)).toEqual({}); expect(pruneHeldReactions([group("👍", false)], held)).toEqual(held); }); it("does not touch kinds the viewer did not change", () => { const held = holdReactions({}, [group("👍", false)], [group("👍", true)]); const loaded = [group("👍", false), group("❤️", true)]; const shown = applyHeldReactions(loaded, held, viewer); expect(shown.find((g) => g.kind === "❤️")).toEqual(loaded[1]); }); it("holds nothing for a failed write that was rolled back", () => { const loaded = [group("👍", false)]; const added = holdReactions({}, loaded, [group("👍", true)]); const held = holdReactions(added, [group("👍", true)], loaded); expect(pruneHeldReactions(loaded, held)).toEqual({}); }); });