diff --git a/desktop/src/features/messages/lib/todoCard.test.mjs b/desktop/src/features/messages/lib/todoCard.test.mjs new file mode 100644 index 0000000000..394d20feff --- /dev/null +++ b/desktop/src/features/messages/lib/todoCard.test.mjs @@ -0,0 +1,364 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countDoneItems, + extractTodoCard, + MAX_TODO_CARD_ITEMS, + reduceTodoResponses, + stripTodoCardSentinel, +} from "./todoCard.ts"; + +const TOM_PUBKEY = "aabbccddeeff0011"; +const YASHODA_PUBKEY = "ddeeff00112233aa"; +const BYSTANDER_PUBKEY = "112233aabbccddee"; +const CARD_EVENT_ID = "cafe0000000000000000000000000000"; +const OTHER_CARD_ID = "beef0000000000000000000000000000"; + +// Helper: build a fenced sentinel body containing the given payload. +function withSentinel(prose, payload) { + return `${prose}\n\n\`\`\`buzz:todo-card\n${JSON.stringify(payload)}\n\`\`\``; +} + +const TWO_ITEM_CARD = { + v: 1, + title: "Launch checklist", + items: [ + { id: "a1", text: "Tom: flip the flag", assignee: TOM_PUBKEY }, + { id: "b2", text: "Yashoda: verify dashboards", assignee: YASHODA_PUBKEY }, + ], +}; + +const UNASSIGNED_CARD = { + v: 1, + items: [{ id: "a1", text: "Anyone: empty the keg" }], +}; + +// Helper: build a kind:40009 response event. +let eventCounter = 0; +function response({ + pubkey, + itemId, + done, + createdAt, + cardId = CARD_EVENT_ID, + id, + content, +}) { + eventCounter += 1; + return { + id: id ?? `evt${String(eventCounter).padStart(4, "0")}`, + pubkey, + created_at: createdAt, + kind: 40009, + tags: [ + ["h", "channel-1"], + ["e", cardId], + ["item", itemId], + ], + content: content ?? JSON.stringify({ done }), + sig: "", + }; +} + +// ── extractTodoCard ─────────────────────────────────────────────────────────── + +test("extractTodoCard returns null when no sentinel present", () => { + assert.equal(extractTodoCard("Just a normal message."), null); +}); + +test("extractTodoCard returns null for empty string", () => { + assert.equal(extractTodoCard(""), null); +}); + +test("extractTodoCard parses a valid two-item card", () => { + assert.deepEqual( + extractTodoCard(withSentinel("Launch checklist:", TWO_ITEM_CARD)), + TWO_ITEM_CARD, + ); +}); + +test("extractTodoCard parses items without assignees", () => { + assert.deepEqual( + extractTodoCard(withSentinel("prose", UNASSIGNED_CARD)), + UNASSIGNED_CARD, + ); +}); + +test("extractTodoCard returns null for malformed JSON", () => { + const content = "prose\n\n```buzz:todo-card\n{not json}\n```"; + assert.equal(extractTodoCard(content), null); +}); + +test("extractTodoCard returns null for unterminated fence", () => { + const content = `prose\n\n\`\`\`buzz:todo-card\n${JSON.stringify(TWO_ITEM_CARD)}`; + assert.equal(extractTodoCard(content), null); +}); + +test("extractTodoCard returns null for wrong version", () => { + assert.equal( + extractTodoCard(withSentinel("prose", { ...TWO_ITEM_CARD, v: 2 })), + null, + ); +}); + +test("extractTodoCard returns null for empty item list", () => { + assert.equal( + extractTodoCard(withSentinel("prose", { v: 1, items: [] })), + null, + ); +}); + +test("extractTodoCard returns null above the item cap", () => { + const items = Array.from({ length: MAX_TODO_CARD_ITEMS + 1 }, (_, i) => ({ + id: `item-${i}`, + text: `Task ${i}`, + })); + assert.equal(extractTodoCard(withSentinel("prose", { v: 1, items })), null); +}); + +test("extractTodoCard accepts exactly the item cap", () => { + const items = Array.from({ length: MAX_TODO_CARD_ITEMS }, (_, i) => ({ + id: `item-${i}`, + text: `Task ${i}`, + })); + assert.notEqual( + extractTodoCard(withSentinel("prose", { v: 1, items })), + null, + ); +}); + +test("extractTodoCard returns null for duplicate item ids", () => { + const payload = { + v: 1, + items: [ + { id: "a1", text: "one" }, + { id: "a1", text: "two" }, + ], + }; + assert.equal(extractTodoCard(withSentinel("prose", payload)), null); +}); + +test("extractTodoCard returns null for non-string item fields", () => { + const payload = { v: 1, items: [{ id: 7, text: "one" }] }; + assert.equal(extractTodoCard(withSentinel("prose", payload)), null); +}); + +test("extractTodoCard returns null for empty assignee", () => { + const payload = { v: 1, items: [{ id: "a1", text: "one", assignee: "" }] }; + assert.equal(extractTodoCard(withSentinel("prose", payload)), null); +}); + +// ── stripTodoCardSentinel ───────────────────────────────────────────────────── + +test("stripTodoCardSentinel removes the fence and keeps the prose", () => { + const content = withSentinel("Launch checklist:", TWO_ITEM_CARD); + assert.equal(stripTodoCardSentinel(content), "Launch checklist:\n"); +}); + +test("stripTodoCardSentinel keeps trailing prose after the fence", () => { + const content = `${withSentinel("Before.", TWO_ITEM_CARD)}\nAfter.`; + assert.equal(stripTodoCardSentinel(content), "Before.\n\nAfter."); +}); + +test("stripTodoCardSentinel is a no-op without a sentinel", () => { + assert.equal(stripTodoCardSentinel("plain message"), "plain message"); +}); + +// ── reduceTodoResponses ─────────────────────────────────────────────────────── + +test("no responses → all items pending", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, []); + assert.deepEqual(state.get("a1"), { + done: false, + completedBy: null, + completedAt: null, + }); + assert.deepEqual(state.get("b2"), { + done: false, + completedBy: null, + completedAt: null, + }); + assert.equal(countDoneItems(TWO_ITEM_CARD, state), 0); +}); + +test("assignee check-off marks the item done with attribution", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: true, createdAt: 100 }), + ]); + assert.deepEqual(state.get("a1"), { + done: true, + completedBy: TOM_PUBKEY, + completedAt: 100, + }); + assert.equal(state.get("b2")?.done, false); + assert.equal(countDoneItems(TWO_ITEM_CARD, state), 1); +}); + +test("latest response per pubkey wins — un-check reverses a check", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: true, createdAt: 100 }), + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: false, createdAt: 200 }), + ]); + assert.deepEqual(state.get("a1"), { + done: false, + completedBy: null, + completedAt: null, + }); +}); + +test("out-of-order delivery folds identically", () => { + const later = response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + done: false, + createdAt: 200, + }); + const earlier = response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + done: true, + createdAt: 100, + }); + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + later, + earlier, + ]); + assert.equal(state.get("a1")?.done, false); +}); + +test("same created_at ties break by event id", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + done: true, + createdAt: 100, + id: "bbb", + }), + response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + done: false, + createdAt: 100, + id: "aaa", + }), + ]); + // "bbb" sorts after "aaa" → done:true wins. + assert.equal(state.get("a1")?.done, true); +}); + +test("non-assignee completion counts with attribution", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: BYSTANDER_PUBKEY, + itemId: "a1", + done: true, + createdAt: 100, + }), + ]); + assert.deepEqual(state.get("a1"), { + done: true, + completedBy: BYSTANDER_PUBKEY, + completedAt: 100, + }); +}); + +test("assignee's response overrides a non-assignee responder", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: BYSTANDER_PUBKEY, + itemId: "a1", + done: true, + createdAt: 200, + }), + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: false, createdAt: 100 }), + ]); + // The assignee has responded (done:false) — their state wins even though a + // bystander's completion is more recent. + assert.equal(state.get("a1")?.done, false); +}); + +test("unassigned item: any responder completes, most recent attributed", () => { + const state = reduceTodoResponses(UNASSIGNED_CARD, CARD_EVENT_ID, [ + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: true, createdAt: 100 }), + response({ + pubkey: YASHODA_PUBKEY, + itemId: "a1", + done: true, + createdAt: 200, + }), + ]); + assert.deepEqual(state.get("a1"), { + done: true, + completedBy: YASHODA_PUBKEY, + completedAt: 200, + }); +}); + +test("unassigned item: un-check only removes the un-checker's completion", () => { + const state = reduceTodoResponses(UNASSIGNED_CARD, CARD_EVENT_ID, [ + response({ pubkey: TOM_PUBKEY, itemId: "a1", done: true, createdAt: 100 }), + response({ + pubkey: YASHODA_PUBKEY, + itemId: "a1", + done: true, + createdAt: 200, + }), + response({ + pubkey: YASHODA_PUBKEY, + itemId: "a1", + done: false, + createdAt: 300, + }), + ]); + // Yashoda un-checked hers; Tom's completion still stands. + assert.deepEqual(state.get("a1"), { + done: true, + completedBy: TOM_PUBKEY, + completedAt: 100, + }); +}); + +test("responses for another card are ignored", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + done: true, + createdAt: 100, + cardId: OTHER_CARD_ID, + }), + ]); + assert.equal(state.get("a1")?.done, false); +}); + +test("responses for unknown item ids are ignored", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: TOM_PUBKEY, + itemId: "nope", + done: true, + createdAt: 100, + }), + ]); + assert.equal(countDoneItems(TWO_ITEM_CARD, state), 0); +}); + +test("malformed response content is ignored", () => { + const state = reduceTodoResponses(TWO_ITEM_CARD, CARD_EVENT_ID, [ + response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + createdAt: 100, + content: "not json", + }), + response({ + pubkey: TOM_PUBKEY, + itemId: "a1", + createdAt: 200, + content: JSON.stringify({ done: "yes" }), + }), + ]); + assert.equal(state.get("a1")?.done, false); +}); diff --git a/desktop/src/features/messages/lib/todoCard.ts b/desktop/src/features/messages/lib/todoCard.ts new file mode 100644 index 0000000000..22d7a87cbb --- /dev/null +++ b/desktop/src/features/messages/lib/todoCard.ts @@ -0,0 +1,252 @@ +/** + * Utilities for the `buzz:todo-card` sentinel — an interactive to-do card + * embedded in an ordinary stream message (kind:9 / kind:40002) body. + * + * Wire format (v1, authored by agents via buzz-sdk): + * + * ``` + * ```buzz:todo-card + * {"v":1,"title":"…","items":[{"id":"…","text":"…","assignee":""}]} + * ``` + * ``` + * + * The prose above the fence is a plaintext fallback for non-card clients. + * The desktop detects the sentinel here, suppresses the prose, and renders a + * `TodoCardAttachment`. Check-offs are kind:40009 responses signed by the + * clicking user (tags: `e` → card event id, `item` → item id, `h` → channel; + * content: `{"done":true|false}`); card state is a pure client-side fold over + * those events — the relay stores raw events only. + */ + +import type { RelayEvent } from "@/shared/api/types"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A single to-do item in the card payload. */ +export type TodoCardItem = { + /** Card-unique item id, referenced by response `item` tags. */ + id: string; + text: string; + /** Hex pubkey of the assignee. Absent = anyone may complete the item. */ + assignee?: string; +}; + +/** The structured payload embedded in the `buzz:todo-card` sentinel block. */ +export type TodoCardPayload = { + v: 1; + title?: string; + items: TodoCardItem[]; +}; + +/** Folded state of one item after replaying its kind:40009 responses. */ +export type TodoItemState = { + done: boolean; + /** Pubkey whose response completed the item (may differ from assignee). */ + completedBy: string | null; + /** `created_at` of the completing response. */ + completedAt: number | null; +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FENCE_OPEN = "```buzz:todo-card"; +const FENCE_CLOSE = "```"; + +/** MVP cap — payloads with more items are rejected (prose fallback). */ +export const MAX_TODO_CARD_ITEMS = 20; + +// ── Extractor ───────────────────────────────────────────────────────────────── + +/** + * Extract the `TodoCardPayload` from a message body, if present. + * + * Returns `null` when: + * - the sentinel fence is absent + * - the JSON inside is malformed + * - the parsed value doesn't match the expected shape + * - the payload exceeds `MAX_TODO_CARD_ITEMS` or has duplicate item ids + * + * Never throws — all errors are swallowed so this is safe to call in the + * render path. + */ +export function extractTodoCard(content: string): TodoCardPayload | null { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return null; + + // The JSON starts on the line after the opening fence. + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) return null; + + // The JSON ends at the next closing ``` that appears on its own line. + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) return null; + + const json = content.slice(jsonStart + 1, closeIdx).trim(); + if (!json) return null; + + try { + const parsed: unknown = JSON.parse(json); + return isTodoCardPayload(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Strip the `buzz:todo-card` sentinel block (and any preceding blank line) + * from a message body. Returns the original string unchanged when no sentinel + * is present. + * + * Used so the prose fallback is rendered without the raw code block. + */ +export function stripTodoCardSentinel(content: string): string { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return content; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, openIdx); + if (closeIdx === -1) return content; + + const afterFence = closeIdx + `\n${FENCE_CLOSE}`.length; + // Trim a preceding blank line so the prose doesn't gain a trailing gap. + const prose = content.slice(0, openIdx).replace(/\n{2,}$/, "\n"); + return prose + content.slice(afterFence); +} + +// ── Response fold ───────────────────────────────────────────────────────────── + +function responseTargetsCard(event: RelayEvent, cardEventId: string): boolean { + return event.tags.some((tag) => tag[0] === "e" && tag[1] === cardEventId); +} + +function responseItemId(event: RelayEvent): string | null { + const value = event.tags.find((tag) => tag[0] === "item")?.[1]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function responseDone(event: RelayEvent): boolean | null { + try { + const parsed = JSON.parse(event.content) as { done?: unknown }; + return typeof parsed.done === "boolean" ? parsed.done : null; + } catch { + return null; + } +} + +/** + * Fold kind:40009 responses into per-item state. + * + * Semantics (MVP policy — display-side, the relay never interprets these): + * - Only the latest response per `(item, pubkey)` counts, so a responder can + * un-check their own completion by publishing `{"done":false}` — and only + * their own. + * - An assigned item follows its assignee's latest response when the assignee + * has responded; otherwise any responder's completion counts, attributed to + * the most recent completer ("completed by X" when X ≠ assignee). + * - An unassigned item is done when any responder's latest response is + * `{"done":true}`, attributed to the most recent completer. + * + * Malformed responses (no `item` tag, unknown item id, non-boolean `done`, + * wrong card `e` tag) are ignored. Never throws. + */ +export function reduceTodoResponses( + card: TodoCardPayload, + cardEventId: string, + events: Iterable, +): Map { + const itemsById = new Map(card.items.map((item) => [item.id, item])); + + // Latest response per (item, pubkey), replayed in deterministic order. + const sorted = [...events] + .filter((event) => responseTargetsCard(event, cardEventId)) + .sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + ); + + const latestByItemAndPubkey = new Map< + string, + Map + >(); + for (const event of sorted) { + const itemId = responseItemId(event); + if (itemId === null || !itemsById.has(itemId)) continue; + const done = responseDone(event); + if (done === null || !event.pubkey) continue; + + let byPubkey = latestByItemAndPubkey.get(itemId); + if (!byPubkey) { + byPubkey = new Map(); + latestByItemAndPubkey.set(itemId, byPubkey); + } + byPubkey.set(event.pubkey, { done, createdAt: event.created_at }); + } + + const state = new Map(); + for (const item of card.items) { + const byPubkey = latestByItemAndPubkey.get(item.id); + const assigneeLatest = item.assignee + ? byPubkey?.get(item.assignee) + : undefined; + + if (assigneeLatest) { + state.set(item.id, { + done: assigneeLatest.done, + completedBy: assigneeLatest.done ? (item.assignee ?? null) : null, + completedAt: assigneeLatest.done ? assigneeLatest.createdAt : null, + }); + continue; + } + + // No assignee response — the most recent still-active completion wins. + let completedBy: string | null = null; + let completedAt: number | null = null; + for (const [pubkey, latest] of byPubkey ?? []) { + if (!latest.done) continue; + if (completedAt === null || latest.createdAt > completedAt) { + completedBy = pubkey; + completedAt = latest.createdAt; + } + } + state.set(item.id, { + done: completedBy !== null, + completedBy, + completedAt, + }); + } + + return state; +} + +/** Count of done items for the card-level "n of m done" line. */ +export function countDoneItems( + card: TodoCardPayload, + state: Map, +): number { + return card.items.filter((item) => state.get(item.id)?.done).length; +} + +// ── Type-guard ───────────────────────────────────────────────────────────────── + +function isTodoCardItem(v: unknown): v is TodoCardItem { + if (typeof v !== "object" || v === null) return false; + const item = v as Record; + return ( + typeof item.id === "string" && + item.id.length > 0 && + typeof item.text === "string" && + (item.assignee === undefined || + (typeof item.assignee === "string" && item.assignee.length > 0)) + ); +} + +function isTodoCardPayload(v: unknown): v is TodoCardPayload { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + if (p.v !== 1) return false; + if (p.title !== undefined && typeof p.title !== "string") return false; + if (!Array.isArray(p.items) || p.items.length === 0) return false; + if (p.items.length > MAX_TODO_CARD_ITEMS) return false; + if (!p.items.every(isTodoCardItem)) return false; + const ids = new Set(p.items.map((item) => (item as TodoCardItem).id)); + return ids.size === p.items.length; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9f55e712f1..f42de2099e 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -35,6 +35,8 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; +import { extractTodoCard } from "@/features/messages/lib/todoCard"; +import { TodoCardAttachment } from "@/features/messages/ui/TodoCardAttachment"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; @@ -353,6 +355,20 @@ export const MessageRow = React.memo( /> ); } + // Interactive to-do card sentinel replaces the prose fallback. + // Requires a channelId (responses carry it as `h`); surfaces + // without one render the prose + fence instead. + const todoCard = channelId ? extractTodoCard(message.body) : null; + if (todoCard && channelId) { + return ( + + ); + } } return ( diff --git a/desktop/src/features/messages/ui/TodoCardAttachment.tsx b/desktop/src/features/messages/ui/TodoCardAttachment.tsx new file mode 100644 index 0000000000..ad0c19dbde --- /dev/null +++ b/desktop/src/features/messages/ui/TodoCardAttachment.tsx @@ -0,0 +1,234 @@ +import { ListTodo } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { + countDoneItems, + reduceTodoResponses, + type TodoCardPayload, + type TodoItemState, +} from "@/features/messages/lib/todoCard"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + sendCardResponse, + subscribeToCardResponses, +} from "@/shared/api/cardResponses"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { RelayEvent, UserProfileSummary } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + Attachment, + AttachmentContent, + AttachmentDescription, + AttachmentMedia, + AttachmentTitle, +} from "@/shared/ui/attachment"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type TodoCardAttachmentProps = { + card: TodoCardPayload; + /** Channel the card message lives in — 40009 responses carry it as `h`. */ + channelId: string; + /** Event id of the card message — 40009 responses reference it via `e`. */ + cardEventId: string; + className?: string; +}; + +function profileName( + profiles: Record | undefined, + pubkey: string, +): string { + const profile = profiles?.[pubkey.toLowerCase()]; + return profile?.displayName ?? profile?.name ?? truncatePubkey(pubkey); +} + +export function TodoCardAttachment({ + card, + channelId, + cardEventId, + className, +}: TodoCardAttachmentProps) { + const identityQuery = useIdentityQuery(); + const ownPubkey = identityQuery.data?.pubkey ?? null; + + const [itemState, setItemState] = React.useState>( + () => reduceTodoResponses(card, cardEventId, []), + ); + const [pendingItemIds, setPendingItemIds] = React.useState>( + () => new Set(), + ); + // The subscription effect owns the fold input; clicks append the publish + // acknowledgement through this ref so both paths share one `seenEvents` map. + const appendEventRef = React.useRef<(event: RelayEvent) => void>(() => {}); + + React.useEffect(() => { + let disposed = false; + let cleanup: (() => void) | null = null; + const seenEvents = new Map(); + + function updateState() { + if (disposed) return; + setItemState(reduceTodoResponses(card, cardEventId, seenEvents.values())); + } + + appendEventRef.current = (event: RelayEvent) => { + if (disposed || seenEvents.has(event.id)) return; + seenEvents.set(event.id, event); + updateState(); + }; + + updateState(); + subscribeToCardResponses(channelId, cardEventId, (event) => { + appendEventRef.current(event); + }) + .then((dispose) => { + if (disposed) { + void dispose(); + return; + } + cleanup = () => void dispose(); + }) + .catch((error) => { + console.error("[TodoCardAttachment] subscription failed:", error); + }); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [card, cardEventId, channelId]); + + const doneCount = countDoneItems(card, itemState); + + // Assignees + completers, for avatars and "completed by" attribution. + const profilePubkeys = React.useMemo(() => { + const pubkeys = new Set(); + for (const item of card.items) { + if (item.assignee) pubkeys.add(item.assignee); + const completedBy = itemState.get(item.id)?.completedBy; + if (completedBy) pubkeys.add(completedBy); + } + return [...pubkeys]; + }, [card.items, itemState]); + const profilesQuery = useUsersBatchQuery(profilePubkeys); + const profiles = profilesQuery.data?.profiles; + + async function handleToggle(itemId: string, nextDone: boolean) { + if (pendingItemIds.has(itemId)) return; + setPendingItemIds((prev) => new Set(prev).add(itemId)); + try { + const event = await sendCardResponse( + channelId, + cardEventId, + itemId, + nextDone, + ); + // The relay also fans the event back through the subscription; the + // seen-map dedupes, so folding the acknowledgement here is just the + // faster path. + appendEventRef.current(event); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update the to-do item.", + ); + } finally { + setPendingItemIds((prev) => { + const next = new Set(prev); + next.delete(itemId); + return next; + }); + } + } + + return ( + + + + + + {card.title ?? "To-do"} + + {doneCount} of {card.items.length} done + +
    + {card.items.map((item) => { + const state = itemState.get(item.id); + const done = state?.done ?? false; + const completedBy = state?.completedBy ?? null; + const pending = pendingItemIds.has(item.id); + // Un-checking replays as "your latest response wins", so only the + // standing completer can un-check their own completion. + const canToggle = + ownPubkey !== null && + !pending && + (!done || completedBy === ownPubkey); + const completerProfile = completedBy + ? profiles?.[completedBy.toLowerCase()] + : undefined; + const completedByOther = + done && completedBy !== null && completedBy !== item.assignee; + + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} diff --git a/desktop/src/shared/api/cardResponses.ts b/desktop/src/shared/api/cardResponses.ts new file mode 100644 index 0000000000..815edf36cf --- /dev/null +++ b/desktop/src/shared/api/cardResponses.ts @@ -0,0 +1,61 @@ +/** + * Publish + subscribe helpers for kind:40009 interactive-card responses. + * + * Lives outside `relayClientSession.ts` (size-ratcheted) and composes its + * public surface: `publishEvent` for the signed check-off and `subscribeLive` + * for the per-card response stream. + */ + +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_CARD_RESPONSE } from "@/shared/constants/kinds"; + +/** + * Publish a kind:40009 check-off response for an interactive card item, + * signed with the current user's key so the click is attributable. + * Resolves with the signed event once the relay acknowledges it. + */ +export async function sendCardResponse( + channelId: string, + cardEventId: string, + itemId: string, + done: boolean, +): Promise { + const event = await signRelayEvent({ + kind: KIND_CARD_RESPONSE, + content: JSON.stringify({ done }), + tags: [ + ["h", channelId], + ["e", cardEventId], + ["item", itemId], + ], + }); + + return relayClient.publishEvent( + event, + "Timed out while updating the to-do item.", + "Failed to update the to-do item.", + ); +} + +/** + * Subscribe to kind:40009 responses for one card, with history replay so a + * reload reconstructs card state. Scoped by `#e` (the card's event id) so + * regular channel traffic never reaches the card's fold. + */ +export async function subscribeToCardResponses( + channelId: string, + cardEventId: string, + onEvent: (event: RelayEvent) => void, +) { + return relayClient.subscribeLive( + { + kinds: [KIND_CARD_RESPONSE], + "#h": [channelId], + "#e": [cardEventId], + limit: 500, + }, + onEvent, + ); +} diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f851e459af..13c81d0f7b 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -20,6 +20,10 @@ export const KIND_STREAM_MESSAGE_EDIT = 40003; export const KIND_CHANNEL_THREAD_SUMMARY = 39005; export const KIND_CHANNEL_WINDOW_BOUNDS = 39006; export const KIND_STREAM_MESSAGE_DIFF = 40008; +// Interactive-card check-off response (e tag → card event, item tag → item). +// Deliberately absent from every timeline/unread set below: responses are not +// rows — TodoCardAttachment folds them via its own per-card subscription. +export const KIND_CARD_RESPONSE = 40009; export const KIND_REMINDER = 40007; export const KIND_SYSTEM_MESSAGE = 40099; export const KIND_JOB_REQUEST = 43001;