Friends' Claude Code presence and usage leaderboards via /who.

Requires Claude Code 2.1.272 with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and Bun. Load this folder using claude --plugin-dir /absolute/path/to/this/folder.
Before joining: this uploads 30 days of raw assistant token metadata to the configured relay. It sends display name, hashed session identifier, presence and messageId, requestId, ts, model, input, output, cacheWrite, cacheRead. It does not send conversation text, file/project names or paths, commands, tool inputs or environment variables. Member keys authenticate only to their issuing relay. The relay retains usage for 60 days.
Set pluginConfigs["claude-who"].options in Claude settings to { "relayUrl": "https://YOUR-RELAY", "displayName": "YOUR_NAME" }.
Run /who help, /who create friends or /who join CODE NAME. Creator departure deletes the entire group. See the repository README for full deployment, configuration, behavior and testing instructions.
For private messages, open /who messages, choose a friend and send. Incoming messages offer Reply and Dismiss. Explicit message text is temporarily stored on the relay (not end-to-end encrypted), expires after 24 hours, and never enters Claude's conversation. Reply/Dismiss clears the body; there is no message history. See the root README for exact cleanup and backup limits.
hooks/index.tsx 794 lines1/* @jsx h */
2import type { Register } from "claude-code";
3import { WhoClient } from "./client";
4import { Leaderboard, clean, compact, windowSize, type Period } from "./pane";
5const ID = "who-leaderboard";
6export const register: Register = (on, options) => {
7 let client: WhoClient | undefined, app: Leaderboard | undefined;
8 on("session.start", async ($, e, next) => {
9 try {
10 client = new WhoClient(
11 {
12 get: (key) => $.store.get(key),
13 set: (key, value) => $.store.set(key, value),
14 remove: (key) => $.store.delete(key),
15 keys: () => $.store.keys(),
16 fetch: (url, init) => $.http.fetch(url, init),
17 run: (argv, init) => $.process.run(argv, init),
18 status: (text) => $.ui.status(text),
19 now: () => $.clock.now(),
20 after: (ms, fn) => $.clock.after(ms, fn),
21 root: $.plugin.root,
22 },
23 String(options.relayUrl ?? ""),
24 String(options.displayName ?? "me"),
25 );
26 await client.init(await $.session.id());
27 app = new Leaderboard(client, {
28 invalidate: () => $.ui.invalidate("ui.render"),
29 open: (focus) =>
30 $.ui.open({
31 id: ID,
32 title: "Claude Who",
33 rows: 26,
34 ...(focus ? { focus: true as const } : {}),
35 }),
36 close: () => $.ui.close({ id: ID }),
37 defer: (fn) => {
38 $.clock.after(0, fn);
39 },
40 save: (open) => $.store.set("ui:open", open),
41 });
42 const active = client,
43 pane = app;
44 await $.command.register({
45 name: "who",
46 description: "Live friends leaderboard",
47 argumentHint:
48 "[today|week|month|create|join|members|messages|close|help]",
49 immediate: true,
50 });
51 $.clock.every(30000, () => {
52 void active.heartbeat();
53 if (pane.opened && !pane.loading) void pane.refresh();
54 });
55 $.clock.every(10000, () => {
56 void pane.pollMessages();
57 });
58 $.clock.every(300000, () => {
59 void active
60 .sync()
61 .then(() => {
62 if (pane.opened) void pane.refresh();
63 })
64 .catch(() => $.ui.status("who: offline"));
65 });
66 const reopen = await $.store.get("ui:open").catch(() => false);
67 $.clock.after(0, () => {
68 void active.heartbeat();
69 void active.sync().catch(() => $.ui.status("who: offline"));
70 if (reopen === true)
71 void pane.show("today", undefined, false).catch(() => {});
72 });
73 } catch {
74 $.ui.status("who: setup failed");
75 }
76 return next(e);
77 });
78 on("turn.start", async ($, e, next) => {
79 if (!("agentId" in e)) await client?.turn("working");
80 return next(e);
81 });
82 on("turn.complete", async ($, e, next) => {
83 await client?.turn("idle", e.agentId);
84 if (e.agentId === undefined && app?.opened) void app.refresh();
85 return next(e);
86 });
87 on("command.run", { command: "who" }, async ($, e) => {
88 if (!client || !app) return { text: "Plugin is starting; try again." };
89 const parts = e.args.trim().split(/\s+/).filter(Boolean),
90 command = parts[0];
91 if (!command || ["today", "week", "month"].includes(command)) {
92 if (parts.length > 2)
93 return { text: "Usage: /who [today|week|month] [group]" };
94 await app.show((command ?? "today") as Period, parts[1]);
95 return {};
96 }
97 if (command === "close") {
98 await app.close();
99 return {};
100 }
101 if (command === "refresh") {
102 await app.show();
103 return {};
104 }
105 if (command === "create" && parts.length === 1) {
106 await app.show();
107 app.edit("create");
108 return {};
109 }
110 if (command === "join" && parts.length === 1) {
111 await app.show();
112 app.edit("join");
113 return {};
114 }
115 if (command === "messages") {
116 if (parts.length > 2) return { text: "Usage: /who messages [group]" };
117 await app.show(app.period, parts[1]);
118 app.edit("messages");
119 return {};
120 }
121 if (command === "members") {
122 await app.show(app.period, parts[1]);
123 app.edit("members");
124 return {};
125 }
126 const text = await client.command(
127 command === "text" ? parts.slice(1).join(" ") : e.args,
128 );
129 if (["create", "join", "leave", "default"].includes(command))
130 app.group = "";
131 if (app.opened) void app.refresh();
132 return {
133 text:
134 command === "help"
135 ? `/who opens the live leaderboard. Today / Week / Month buttons change periods. Esc returns to your prompt and keeps the board open. /who close closes it. /who text today prints a text board.\n${text}`
136 : text,
137 };
138 });
139 on("ui.close", { id: ID }, async ($, e, next) => {
140 const result = await next(e);
141 if (!result || !("deny" in result) || !result.deny) app?.markClosed();
142 return result;
143 });
144 on("ui.render", { component: "Pane" }, ($, e, next) => {
145 if (e.requestId !== ID || !app || e.surface === "mobile") return next(e);
146 const { Box, Text, Button, Select, Input } = $.ui.resolve(e),
147 pane = app;
148 const columns = e.props.bodyColumns;
149 if (columns < 34)
150 return (
151 <Text dimColor>
152 Widen the pane to 34 columns to see the leaderboard.
153 </Text>
154 );
155 const rows =
156 pane.fresh || e.props.scroll.bodyRows === 0
157 ? 26
158 : e.props.scroll.bodyRows;
159 pane.fresh = false;
160 const defer = (fn: () => Promise<unknown>) =>
161 pane.host.defer(() => {
162 void fn().catch(() => {});
163 });
164 const title = (
165 <Text bold color="cyan" wrap="truncate-end">
166 CLAUDE WHO / {clean(pane.data?.groupName || "Your circle")}
167 </Text>
168 );
169 if (pane.form === "messages" || pane.form === "compose") {
170 const message = pane.currentMessage;
171 const friends = pane.recipients;
172 if (pane.form === "compose" && !pane.reply && friends.length === 0)
173 return (
174 <Box flexDirection="column" width={columns}>
175 {title}
176 <Text bold>New private message</Text>
177 <Text>
178 {pane.loading
179 ? "Loading group members…"
180 : pane.error
181 ? "Could not load recipients. Try refreshing."
182 : !pane.data
183 ? "Loading group members…"
184 : "No other members in this group. Invite a friend first."}
185 </Text>
186 <Box flexDirection="row" columnGap={1}>
187 <Button
188 key="refresh-recipients"
189 label="Refresh members"
190 onPress={() => {
191 void pane.refresh();
192 }}
193 />
194 <Button
195 key="compose-back"
196 label="Back"
197 onPress={() => pane.edit("messages")}
198 />
199 </Box>
200 {pane.error ? <Text color="yellow">{pane.error}</Text> : null}
201 </Box>
202 );
203 const text = Array.from(
204 (message?.text ?? "").replace(
205 /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g,
206 " ",
207 ),
208 );
209 const width = Math.max(16, columns - 4),
210 pageSize = width * Math.max(1, Math.min(5, rows - 14));
211 const pages = Math.max(1, Math.ceil(text.length / pageSize));
212 pane.messagePage = Math.min(pane.messagePage, pages - 1);
213 const shown = text.slice(
214 pane.messagePage * pageSize,
215 (pane.messagePage + 1) * pageSize,
216 );
217 const lines = Array.from(
218 { length: Math.ceil(shown.length / width) },
219 (_, i) => shown.slice(i * width, (i + 1) * width).join(""),
220 );
221 return (
222 <Box flexDirection="column" width={columns}>
223 {title}
224 <Text bold>
225 {pane.form === "compose"
226 ? pane.reply
227 ? `Reply to ${clean(pane.reply.senderName)}`
228 : "New private message"
229 : `Messages · ${pane.messages.length} pending`}
230 </Text>
231 <Text dimColor>
232 Private to recipient · expires in 24h · no history
233 </Text>
234 {pane.form === "compose" ? (
235 <Box flexDirection="column">
236 {pane.reply ? (
237 <Text dimColor wrap="truncate-end">
238 {clean(pane.reply.text)}
239 </Text>
240 ) : (
241 <Select
242 key="message-recipient"
243 label="To"
244 value={pane.recipientId || "choose-recipient"}
245 options={[
246 ...(!pane.recipientId
247 ? [
248 {
249 label: "Choose a friend",
250 value: "choose-recipient",
251 },
252 ]
253 : []),
254 ...friends.map((m) => ({ label: m.name, value: m.id })),
255 ]}
256 onSelect={(v) =>
257 pane.chooseRecipient(v === "choose-recipient" ? "" : v)
258 }
259 />
260 )}
261 <Input
262 key="message-draft"
263 label="Message"
264 placeholder="Say hello…"
265 value={pane.draft}
266 autoFocus
267 submitLabel="send"
268 onInput={(v) => {
269 if (pane.actionBusy) return;
270 pane.draft = v.slice(0, 1000);
271 }}
272 onSubmit={(v) => {
273 if (pane.actionBusy) return;
274 pane.draft = v.slice(0, 1000);
275 pane.send();
276 }}
277 />
278 <Box flexDirection="row" columnGap={1}>
279 <Button
280 key="send-message"
281 label={pane.actionBusy ? "Sending…" : "Send"}
282 onPress={() => pane.send()}
283 />
284 <Button
285 key="cancel-message"
286 label="Back"
287 onPress={() => {
288 if (!pane.actionBusy) pane.edit("messages");
289 }}
290 />
291 </Box>
292 </Box>
293 ) : (
294 <Box flexDirection="column">
295 {message ? (
296 <Box flexDirection="column">
297 <Select
298 key="inbox-message"
299 label="From"
300 value={`${message.senderId}:${message.id}`}
301 options={pane.messages.map((m) => ({
302 label: `${clean(m.senderName)} · ${new Date(m.createdAt).toISOString().slice(11, 16)} UTC`,
303 value: `${m.senderId}:${m.id}`,
304 }))}
305 onSelect={(v) => {
306 pane.messageKey = v;
307 pane.messagePage = 0;
308 pane.host.invalidate();
309 }}
310 />
311 {lines.map((line, i) => (
312 <Text key={`message-line-${i}`}>{line}</Text>
313 ))}
314 {pages > 1 ? (
315 <Box flexDirection="row" columnGap={1}>
316 <Button
317 key="message-prev-page"
318 label="←"
319 onPress={() => {
320 pane.messagePage = Math.max(0, pane.messagePage - 1);
321 pane.host.invalidate();
322 }}
323 />
324 <Text dimColor>
325 {pane.messagePage + 1}/{pages}
326 </Text>
327 <Button
328 key="message-next-page"
329 label="→"
330 onPress={() => {
331 pane.messagePage = Math.min(
332 pages - 1,
333 pane.messagePage + 1,
334 );
335 pane.host.invalidate();
336 }}
337 />
338 </Box>
339 ) : null}
340 <Box flexDirection="row" columnGap={1}>
341 <Button
342 key="reply-message"
343 label="Reply"
344 onPress={() => pane.compose(message)}
345 />
346 <Button
347 key="dismiss-message"
348 label={pane.actionBusy ? "Working…" : "Dismiss"}
349 onPress={() => pane.dismiss()}
350 />
351 </Box>
352 <Text dimColor>
353 Replying or dismissing clears this message.
354 </Text>
355 </Box>
356 ) : (
357 <Text dimColor>
358 {pane.inboxLoading
359 ? "Checking messages…"
360 : "No pending messages."}
361 </Text>
362 )}
363 <Box flexDirection="row" columnGap={1}>
364 <Button
365 key="new-message"
366 label="New message"
367 onPress={() => pane.compose()}
368 />
369 <Button
370 key="refresh-messages"
371 label="Refresh"
372 onPress={() => {
373 void pane.refreshMessages();
374 }}
375 />
376 <Button
377 key="messages-back"
378 label="Back"
379 onPress={() => pane.edit("board")}
380 />
381 </Box>
382 </Box>
383 )}
384 <Text color={pane.error ? "yellow" : undefined}>
385 {pane.error || pane.notice}
386 </Text>
387 </Box>
388 );
389 }
390 if (pane.form === "members") {
391 const data = pane.data;
392 const people = data?.members ?? [];
393 const removable = people.filter((m) => !m.isCreator);
394 if (!removable.some((m) => m.name === pane.selectedMember))
395 pane.selectedMember = removable[0]?.name ?? "";
396 return (
397 <Box flexDirection="column" width={columns}>
398 {title}
399 <Text bold>Members & invitations</Text>
400 <Text dimColor wrap="truncate-end">
401 {people
402 .map((m) => `${clean(m.name)}${m.isCreator ? " (creator)" : ""}`)
403 .join(" · ")}
404 </Text>
405 {data?.isCreator ? (
406 <Box flexDirection="column">
407 <Box flexDirection="row" columnGap={1}>
408 <Button
409 key="copy-invite"
410 label={pane.actionBusy ? "Working…" : "Copy invite"}
411 onPress={() => pane.copyInvite()}
412 />
413 <Button
414 key="rotate-invite"
415 label="New invite"
416 onPress={() => {
417 pane.confirmation = "rotate";
418 pane.host.invalidate();
419 }}
420 />
421 </Box>
422 {removable.length > 0 ? (
423 <Box flexDirection="column">
424 <Select
425 key="remove-member"
426 label="Member"
427 value={pane.selectedMember}
428 options={removable.map((m) => ({
429 label: m.name,
430 value: m.name,
431 }))}
432 onSelect={(v) => {
433 pane.selectedMember = v;
434 pane.confirmation = null;
435 pane.host.invalidate();
436 }}
437 />
438 <Button
439 key="remove"
440 label="Remove member"
441 onPress={() => {
442 pane.confirmation = "remove";
443 pane.host.invalidate();
444 }}
445 />
446 </Box>
447 ) : (
448 <Text dimColor>No other members yet.</Text>
449 )}
450 {pane.confirmation ? (
451 <Box flexDirection="column">
452 {pane.confirmation === "generate-copy" ? (
453 <Text>No current invite saved on this device.</Text>
454 ) : null}
455 <Text color="yellow">
456 {pane.confirmation === "remove"
457 ? `Remove ${clean(pane.selectedMember)} and delete their data?`
458 : "Replace invite? The previous invitation will stop working."}
459 </Text>
460 <Text dimColor>
461 {pane.confirmation === "remove"
462 ? "Removing a member also replaces the invite."
463 : "Existing members stay connected."}
464 </Text>
465 <Box flexDirection="row" columnGap={1}>
466 <Button
467 key="confirm"
468 label={
469 pane.confirmation === "generate-copy"
470 ? "Generate and copy"
471 : "Confirm"
472 }
473 onPress={() => pane.confirm()}
474 />
475 <Button
476 key="cancel"
477 label="Cancel"
478 onPress={() => {
479 pane.confirmation = null;
480 pane.host.invalidate();
481 }}
482 />
483 </Box>
484 </Box>
485 ) : (
486 <Text dimColor>
487 Only the creator can remove members or manage invites.
488 </Text>
489 )}
490 </Box>
491 ) : (
492 <Text dimColor>
493 The group creator manages invitations and members.
494 </Text>
495 )}
496 <Button
497 key="back-board"
498 label="Back"
499 onPress={() => pane.edit("board")}
500 />
501 <Text color={pane.error ? "yellow" : undefined} wrap="truncate-end">
502 {pane.error || pane.notice}
503 </Text>
504 </Box>
505 );
506 }
507 if (pane.form !== "board")
508 return (
509 <Box flexDirection="column" width={columns}>
510 {title}
511 <Text dimColor>
512 {pane.form === "create"
513 ? "Start a group with your friends"
514 : "Join your friends"}
515 </Text>
516 <Text dimColor>
517 Shares 30 days of token metadata. No conversation text.
518 </Text>
519 {pane.form === "create" ? (
520 <Input
521 key="group-name"
522 label="Group"
523 placeholder="close-friends"
524 value={pane.groupInput}
525 autoFocus
526 onInput={(v) => {
527 pane.groupInput = v;
528 }}
529 onSubmit={(v) => {
530 pane.groupInput = v;
531 pane.submit();
532 }}
533 />
534 ) : (
535 <Box flexDirection="column">
536 <Input
537 key="invite-code"
538 label="Code"
539 placeholder="WHO-XXXX-XXXX"
540 value={pane.codeInput}
541 autoFocus
542 onInput={(v) => {
543 pane.codeInput = v;
544 }}
545 onSubmit={(v) => {
546 pane.codeInput = v;
547 }}
548 />
549 <Input
550 key="your-name"
551 label="Your name"
552 placeholder="sam"
553 value={pane.nameInput}
554 onInput={(v) => {
555 pane.nameInput = v;
556 }}
557 onSubmit={(v) => {
558 pane.nameInput = v;
559 pane.submit();
560 }}
561 />
562 </Box>
563 )}
564 <Box flexDirection="row" columnGap={1}>
565 <Button
566 key="submit"
567 label={
568 pane.loading
569 ? "Connecting…"
570 : pane.form === "create"
571 ? "Create group"
572 : "Join group"
573 }
574 onPress={() => pane.submit()}
575 />
576 <Button
577 key="back"
578 label="Back"
579 dimColor
580 onPress={() => pane.edit("board")}
581 />
582 </Box>
583 <Text color="yellow" wrap="truncate-end">
584 {pane.notice ||
585 pane.error ||
586 "Create uses your configured display name."}
587 </Text>
588 </Box>
589 );
590 const data = pane.data;
591 if (!data && pane.loading)
592 return (
593 <Box flexDirection="column">
594 {title}
595 <Text dimColor>Loading the leaderboard…</Text>
596 </Box>
597 );
598 if (!data?.groups.length)
599 return (
600 <Box flexDirection="column" width={columns}>
601 {title}
602 <Text bold>Your friends. Their flow. A little competition.</Text>
603 <Text dimColor>
604 See who is working and climb the live leaderboard.
605 </Text>
606 <Text> </Text>
607 <Box flexDirection="row" columnGap={1}>
608 <Button
609 key="create"
610 label="Create a group"
611 onPress={() => pane.edit("create")}
612 />
613 <Button
614 key="join"
615 label="Join a group"
616 onPress={() => pane.edit("join")}
617 />
618 </Box>
619 <Text dimColor>
620 Shares token metadata only — never your conversations.
621 </Text>
622 <Text color="yellow" wrap="truncate-end">
623 {pane.error ||
624 (pane.loading ? "Loading your groups…" : pane.notice)}
625 </Text>
626 </Box>
627 );
628 const pageSize = windowSize(rows, data.unreadMessages > 0),
629 pages = Math.max(1, Math.ceil(data.rows.length / pageSize));
630 pane.page = Math.min(pane.page, pages - 1);
631 const visible = data.rows.slice(
632 pane.page * pageSize,
633 (pane.page + 1) * pageSize,
634 ),
635 top = Math.max(1, ...data.rows.map((r) => r.tokens));
636 const me = data.rows.find((r) => r.name === data.me),
637 working = data.rows.filter((r) => r.state === "working").length;
638 return (
639 <Box flexDirection="column" width={columns}>
640 {title}
641 {data.unreadMessages > 0 ? (
642 <Box flexDirection="row" columnGap={1}>
643 <Text
644 bold
645 color="yellow"
646 >{`● ${data.unreadMessages} ${data.unreadMessages === 1 ? "message" : "messages"}`}</Text>
647 <Button
648 key="notification-inbox"
649 label="Open inbox"
650 onPress={() => {
651 if (!pane.actionBusy) pane.edit("messages");
652 }}
653 />
654 </Box>
655 ) : null}
656 <Box flexDirection="row" columnGap={1}>
657 {(["today", "week", "month"] as Period[]).map((period) => (
658 <Button
659 key={period}
660 label={period[0]!.toUpperCase() + period.slice(1)}
661 dimColor={pane.period !== period}
662 onPress={() => pane.change(period)}
663 />
664 ))}
665 <Button
666 key="refresh"
667 label={pane.loading ? "…" : "↻"}
668 onPress={() => defer(() => pane.refresh())}
669 />
670 </Box>
671 <Box flexDirection="row" columnGap={1}>
672 {data.isCreator && (
673 <Button
674 key="copy-invite-board"
675 label={pane.actionBusy ? "Working…" : "Copy invite"}
676 onPress={() => pane.copyInvite()}
677 />
678 )}
679 <Button
680 key="members"
681 label="Members"
682 onPress={() => pane.edit("members")}
683 />
684 <Button
685 key="messages"
686 label={`Messages${data.unreadMessages ? ` (${data.unreadMessages})` : ""}`}
687 onPress={() => {
688 if (!pane.actionBusy) pane.edit("messages");
689 }}
690 />
691 </Box>
692 <Select
693 key="groups"
694 label="Group"
695 value={data.groupId}
696 options={data.groups.map((g) => ({ value: g.id, label: g.name }))}
697 onSelect={(value) => pane.change(pane.period, value)}
698 />
699 <Text bold wrap="truncate-end">
700 {working} working · {data.rows.length} friends · You #
701 {me?.rank ?? "—"} / {compact(me?.tokens ?? 0)}
702 </Text>
703 <Text dimColor wrap="truncate-end">
704 WORK TOKENS · input + output
705 </Text>
706 {visible.map((row) => {
707 const barWidth = Math.max(8, Math.min(24, columns - 24)),
708 filled = Math.round((row.tokens / top) * barWidth);
709 return (
710 <Box key={row.name} flexDirection="column">
711 <Text
712 bold={row.name === data.me}
713 color={row.name === data.me ? "cyan" : undefined}
714 wrap="truncate-end"
715 >
716 {row.rank <= 3
717 ? ["🥇", "🥈", "🥉"][row.rank - 1]
718 : `#${row.rank}`}{" "}
719 {clean(row.name)}
720 {row.name === data.me ? " (you)" : ""} · {compact(row.tokens)}
721 </Text>
722 <Text
723 color={row.state === "working" ? "green" : undefined}
724 dimColor={row.state === "offline"}
725 wrap="truncate-end"
726 >
727 {"█".repeat(filled)}
728 {"░".repeat(barWidth - filled)}{" "}
729 {row.state === "working"
730 ? "●"
731 : row.state === "idle"
732 ? "◐"
733 : "○"}{" "}
734 {clean(row.state)}
735 </Text>
736 <Text dimColor wrap="truncate-end">
737 {row.gap ? `${compact(row.gap)} behind · ` : ""}synced{" "}
738 {row.lastSyncAt
739 ? clean(row.lastSyncAt.slice(11, 16)) + " UTC"
740 : "never"}
741 </Text>
742 </Box>
743 );
744 })}
745 <Box flexDirection="row" columnGap={1}>
746 <Button
747 key="prev"
748 label="←"
749 dimColor={pane.page === 0}
750 onPress={() => {
751 pane.page = Math.max(0, pane.page - 1);
752 pane.host.invalidate();
753 }}
754 />
755 <Text dimColor>
756 {pane.page + 1}/{pages}
757 </Text>
758 <Button
759 key="next"
760 label="→"
761 dimColor={pane.page === pages - 1}
762 onPress={() => {
763 pane.page = Math.min(pages - 1, pane.page + 1);
764 pane.host.invalidate();
765 }}
766 />
767 <Button
768 key="add"
769 label="+ Group"
770 dimColor
771 onPress={() => pane.edit("join")}
772 />
773 <Button
774 key="close"
775 label="Close"
776 dimColor
777 onPress={() => defer(() => pane.close())}
778 />
779 </Box>
780 <Text
781 color={pane.error ? "yellow" : undefined}
782 dimColor={!pane.error}
783 wrap="truncate-end"
784 >
785 {pane.error
786 ? pane.error
787 : pane.notice ||
788 `Live · board 30s / messages 10s · Esc returns to code`}
789 </Text>
790 </Box>
791 );
792 });
793};
794hooks/client.ts 611 lines1export type DirectMessage = {
2 id: string;
3 senderId: string;
4 senderName: string;
5 text: string;
6 createdAt: number;
7 expiresAt: number;
8};
9export class InviteUnavailableError extends Error {}
10export type Host = {
11 get: (key: string) => Promise<unknown>;
12 set: (key: string, value: unknown) => Promise<void>;
13 remove: (key: string) => Promise<void>;
14 keys: () => Promise<string[]>;
15 fetch: (
16 url: string,
17 init?: { method?: string; headers?: Record<string, string>; body?: string },
18 ) => Promise<{ status: number; ok: boolean; text: string }>;
19 run: (
20 argv: readonly string[],
21 init: { stdin: string; timeoutMs: number },
22 ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
23 status: (text: string) => void;
24 now: () => Promise<number>;
25 after: (ms: number, fn: () => void) => { cancel: () => void };
26 root: string;
27};
28type Membership = {
29 groupId: string;
30 groupName: string;
31 name: string;
32 memberKey: string;
33 since: string;
34 relay: string;
35};
36type Presence = {
37 name: string;
38 state: string;
39 since: string;
40 todayTokens: number;
41 rank: number;
42};
43const membershipKey = (id: string) => `membership:${id}`;
44const safeName = (s: unknown) =>
45 typeof s === "string" && /^[\p{L}\p{N}_-]{1,40}$/u.test(s);
46class RelayError extends Error {
47 constructor(
48 message: string,
49 public status = 0,
50 ) {
51 super(message);
52 }
53}
54export class WhoClient {
55 state: "working" | "idle" = "idle";
56 sessionHash = "";
57 private beatChain = Promise.resolve();
58 private syncRunning = false;
59 private cooldownUntil = 0;
60 private cooldownError: RelayError | undefined;
61 constructor(
62 readonly host: Host,
63 readonly relay: string,
64 readonly displayName: string,
65 ) {}
66 async snapshot(period: "today" | "week" | "month", group?: string) {
67 const memberships = await this.memberships();
68 const groups = memberships.map((m) => ({
69 id: m.groupId,
70 name: m.groupName,
71 }));
72 if (!groups.length)
73 return {
74 groups,
75 groupId: "",
76 groupName: "",
77 me: "",
78 isCreator: false,
79 unreadMessages: 0,
80 members: [] as Array<{
81 id: string;
82 name: string;
83 isCreator: boolean;
84 flagged: boolean;
85 }>,
86 rows: [],
87 updatedAt: await this.host.now(),
88 };
89 const m = await this.selected(group);
90 const [board, membership, presence] = await Promise.all([
91 this.request(`/board?period=${period}`, m),
92 this.request("/members", m),
93 this.request("/presence", m, {
94 sessionId: this.sessionHash,
95 state: this.state,
96 }),
97 ]);
98 if (
99 !Array.isArray(membership.members) ||
100 membership.members.some(
101 (v: any) =>
102 typeof v.id !== "string" || !v.id || typeof v.name !== "string",
103 )
104 )
105 throw new Error(
106 "The relay returned an outdated member list. Update the relay and refresh.",
107 );
108 if (!Array.isArray(board.rows) || !Array.isArray(presence.members))
109 throw new Error("Invalid leaderboard response");
110 return {
111 groups,
112 groupId: m.groupId,
113 groupName: m.groupName,
114 me: m.name,
115 isCreator: !!membership.members.find((v: any) => v.name === m.name)
116 ?.isCreator,
117 unreadMessages: Number(presence.unreadMessages) || 0,
118 members: membership.members as Array<{
119 id: string;
120 name: string;
121 isCreator: boolean;
122 flagged: boolean;
123 }>,
124 rows: (
125 board.rows as Array<{
126 name: string;
127 rank: number;
128 tokens: number;
129 gap: number;
130 lastSyncAt: string | null;
131 }>
132 ).map((r) => ({
133 name: String(r.name),
134 rank: Number(r.rank),
135 tokens: Number(r.tokens),
136 gap: Number(r.gap),
137 lastSyncAt: r.lastSyncAt as string | null,
138 state: String(
139 presence.members.find((p: Presence) => p.name === r.name)?.state ??
140 "offline",
141 ),
142 })),
143 updatedAt: await this.host.now(),
144 };
145 }
146 async messageCount(group?: string): Promise<number> {
147 const m = await this.selected(group);
148 const b = await this.request("/messages/count", m);
149 if (!Number.isInteger(b.unreadMessages) || b.unreadMessages < 0)
150 throw new Error("Invalid notification count");
151 return b.unreadMessages;
152 }
153 async inbox(group?: string): Promise<DirectMessage[]> {
154 const m = await this.selected(group);
155 const b = await this.request("/messages", m);
156 if (!Array.isArray(b.messages)) throw new Error("Invalid inbox response");
157 return b.messages.map((v: any) => {
158 if (
159 typeof v.id !== "string" ||
160 typeof v.senderId !== "string" ||
161 typeof v.senderName !== "string" ||
162 typeof v.text !== "string" ||
163 v.text.length > 1000 ||
164 !Number.isFinite(v.createdAt) ||
165 !Number.isFinite(v.expiresAt)
166 )
167 throw new Error("Invalid message response");
168 return {
169 id: v.id,
170 senderId: v.senderId,
171 senderName: v.senderName,
172 text: v.text,
173 createdAt: Number(v.createdAt),
174 expiresAt: Number(v.expiresAt),
175 };
176 });
177 }
178 async sendMessage(
179 recipientId: string,
180 text: string,
181 id: string,
182 group?: string,
183 replyTo?: string,
184 ) {
185 const m = await this.selected(group);
186 await this.request("/messages", m, {
187 id,
188 recipientId,
189 text,
190 ...(replyTo ? { replyTo } : {}),
191 });
192 }
193 async dismissMessage(message: DirectMessage, group?: string) {
194 const m = await this.selected(group);
195 await this.request("/messages/dismiss", m, {
196 id: message.id,
197 senderId: message.senderId,
198 });
199 }
200 async invitation(group?: string) {
201 const m = await this.selected(group);
202 const code = await this.host.get(`invite:${m.groupId}`);
203 if (typeof code !== "string")
204 throw new InviteUnavailableError(
205 "No saved invite. The creator can use New invite to generate one.",
206 );
207 let valid: boolean;
208 try {
209 valid = !!(await this.request("/invite/check", m, { inviteCode: code }))
210 .valid;
211 } catch (e) {
212 if (e instanceof RelayError && e.status === 404)
213 throw new Error(
214 "This relay needs an update before invitations can be copied. Ask its operator to deploy the latest version.",
215 );
216 throw e;
217 }
218 if (!valid)
219 throw new InviteUnavailableError(
220 "This invite was replaced. The creator can generate a new invite.",
221 );
222 return this.inviteText(m.groupName, code);
223 }
224 private inviteText(groupName: string, code: string) {
225 return `Join my Claude Who group: ${groupName}\n\nInstall: https://github.com/SaharCarmel/claude-who#installation\nRelay URL: ${this.relay}\nStart Claude Who and run: /who join ${code} YOUR_NAME\n\nJoining shares the last 30 days of token metadata, never conversation text. Keep this invitation private.`;
226 }
227 async copyInvitation(group?: string) {
228 const text = await this.invitation(group);
229 const result = await this.host.run(
230 ["bun", `${this.host.root}/reader/copy-invitation.ts`],
231 { stdin: text, timeoutMs: 5000 },
232 );
233 if (result.exitCode !== 0)
234 throw new Error(
235 "Clipboard unavailable. Run /who invite to select and copy the invitation manually.",
236 );
237 }
238 async rotateInvite(group?: string) {
239 const m = await this.selected(group);
240 const b = await this.request("/invite/rotate", m, {});
241 await this.host.set(`invite:${m.groupId}`, b.inviteCode);
242 return b.inviteCode as string;
243 }
244 async removeMember(name: string, group?: string) {
245 const m = await this.selected(group);
246 const b = await this.request("/kick", m, { name });
247 if (b.inviteCode) await this.host.set(`invite:${m.groupId}`, b.inviteCode);
248 return `Removed ${name}. The invite was also replaced; copy a new invitation for future members.`;
249 }
250 async init(sessionId: string) {
251 this.sessionHash = Array.from(
252 new Uint8Array(
253 await crypto.subtle.digest(
254 "SHA-256",
255 new TextEncoder().encode(sessionId),
256 ),
257 ),
258 )
259 .map((n) => n.toString(16).padStart(2, "0"))
260 .join("");
261 }
262 async memberships() {
263 const entries: Membership[] = [];
264 for (const key of await this.host.keys()) {
265 if (!key.startsWith("membership:")) continue;
266 const v = (await this.host.get(key)) as Partial<Membership> | undefined;
267 if (
268 v &&
269 typeof v.groupId === "string" &&
270 safeName(v.groupName) &&
271 safeName(v.name) &&
272 typeof v.memberKey === "string" &&
273 /^[a-f0-9]{64}$/.test(v.memberKey) &&
274 typeof v.since === "string" &&
275 v.relay === this.relay
276 )
277 entries.push(v as Membership);
278 }
279 return entries;
280 }
281 private async selected(group?: string) {
282 const all = await this.memberships();
283 if (group) {
284 const matches = all.filter(
285 (m) => m.groupName === group || m.groupId === group,
286 );
287 if (matches.length !== 1)
288 throw new Error(
289 matches.length
290 ? "Group name is ambiguous; use its ID"
291 : "You are not in that group",
292 );
293 return matches[0]!;
294 }
295 const id = await this.host.get("defaultGroup");
296 const m = all.find((m) => m.groupId === id) ?? all[0];
297 if (!m)
298 throw new Error(
299 "Create or join a group first: /who create <group> or /who join <code> <name>",
300 );
301 return m;
302 }
303 private async request(
304 path: string,
305 m?: Membership,
306 data?: unknown,
307 ): Promise<any> {
308 if (this.cooldownError && (await this.host.now()) < this.cooldownUntil)
309 throw this.cooldownError;
310 if (!this.relay)
311 throw new Error("Set relayUrl in the Claude Who plugin configuration");
312 const url = new URL(this.relay);
313 if (
314 url.protocol !== "https:" &&
315 !(
316 url.protocol === "http:" &&
317 ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
318 )
319 )
320 throw new Error("Relay must use HTTPS");
321 if (url.username || url.password || url.search || url.hash)
322 throw new Error(
323 "Relay URL must not contain credentials, query or fragment",
324 );
325 let timer: { cancel: () => void } | undefined;
326 try {
327 const response = await Promise.race([
328 this.host.fetch(this.relay.replace(/\/$/, "") + path, {
329 method: data === undefined ? "GET" : "POST",
330 headers: {
331 "Content-Type": "application/json",
332 ...(m ? { Authorization: `Bearer ${m.memberKey}` } : {}),
333 },
334 ...(data === undefined ? {} : { body: JSON.stringify(data) }),
335 }),
336 new Promise<never>((_, reject) => {
337 timer = this.host.after(10000, () =>
338 reject(new RelayError("Relay timed out")),
339 );
340 }),
341 ]);
342 let b;
343 try {
344 b = JSON.parse(response.text);
345 } catch {
346 throw new RelayError("Invalid relay response");
347 }
348 if (!response.ok) {
349 const error = new RelayError(
350 typeof b.error === "string"
351 ? b.error.slice(0, 200)
352 : "Relay request failed",
353 response.status,
354 );
355 if (response.status === 429 || b.code === "D1_QUOTA_EXCEEDED") {
356 this.cooldownUntil = (await this.host.now()) + 60000;
357 this.cooldownError = error;
358 }
359 throw error;
360 }
361 return b;
362 } finally {
363 timer?.cancel();
364 }
365 }
366 private errorStatus(e: unknown) {
367 this.host.status(
368 /D1 daily quota/i.test(String(e))
369 ? "who: database quota reached"
370 : /refus|policy|denied|blocked/i.test(String(e))
371 ? "who: blocked"
372 : e instanceof RelayError && e.status === 401
373 ? "who: membership revoked"
374 : "who: offline",
375 );
376 }
377 heartbeat() {
378 const run = async () => {
379 try {
380 const all = await this.memberships();
381 if (!all.length) {
382 this.host.status("who: /who create or join");
383 return;
384 }
385 const def = await this.selected();
386 let failed: unknown;
387 for (const m of all) {
388 try {
389 const b = await this.request("/presence", m, {
390 sessionId: this.sessionHash,
391 state: this.state,
392 });
393 if (m.groupId === def.groupId) {
394 const members = b.members as Presence[];
395 const me = members.find((p) => p.name === m.name);
396 this.host.status(
397 (b.unreadMessages
398 ? `✉ ${b.unreadMessages} ${b.unreadMessages === 1 ? "message" : "messages"} · /who messages · `
399 : "") +
400 members.map((p) => `${p.name} ${p.state}`).join(" · ") +
401 ` · you #${me?.rank ?? "?"} today`,
402 );
403 }
404 } catch (e) {
405 if (m.groupId === def.groupId) failed = e;
406 }
407 }
408 if (failed) this.errorStatus(failed);
409 } catch (e) {
410 this.errorStatus(e);
411 }
412 };
413 this.beatChain = this.beatChain.then(run, run);
414 return this.beatChain;
415 }
416 async turn(state: "working" | "idle", agentId?: string) {
417 if (agentId !== undefined) return;
418 this.state = state;
419 await this.heartbeat();
420 }
421 async sync() {
422 if (this.syncRunning) return;
423 this.syncRunning = true;
424 try {
425 for (const m of await this.memberships()) {
426 try {
427 const key = `cursor:${m.groupId}`,
428 cursor = await this.host.get(key);
429 const result = await this.host.run(
430 ["bun", `${this.host.root}/reader/read-usage.ts`],
431 {
432 stdin: JSON.stringify({ cursor, since: m.since }),
433 timeoutMs: 20000,
434 },
435 );
436 if (result.exitCode !== 0) throw new Error("Usage reader failed");
437 const batch = JSON.parse(result.stdout);
438 if (!Array.isArray(batch.records) || !batch.cursor)
439 throw new Error("Invalid reader response");
440 // Only these eight fields cross the network, even if the local reader changes.
441 const records = batch.records.map((r: any) => ({
442 messageId: r.messageId,
443 requestId: r.requestId,
444 ts: r.ts,
445 model: r.model,
446 input: r.input,
447 output: r.output,
448 cacheWrite: r.cacheWrite,
449 cacheRead: r.cacheRead,
450 }));
451 const ack = await this.request("/records", m, { records });
452 if (
453 ![ack.accepted, ack.duplicates, ack.rejected].every(
454 (n) => Number.isInteger(n) && n >= 0,
455 ) ||
456 ack.accepted + ack.duplicates + ack.rejected !== records.length
457 )
458 throw new Error("Invalid sync acknowledgement");
459 // Invalid records were deliberately rejected. A failed request never reaches this write.
460 await this.host.set(key, batch.cursor);
461 } catch (e) {
462 this.errorStatus(e);
463 }
464 }
465 } finally {
466 this.syncRunning = false;
467 }
468 }
469 async command(args: string): Promise<string> {
470 try {
471 return await this.dispatch(args.trim().split(/\s+/).filter(Boolean));
472 } catch (e) {
473 return String(e instanceof Error ? e.message : e)
474 .replace(/[\x00-\x1f\x7f]/g, " ")
475 .slice(0, 300);
476 }
477 }
478 private async dispatch(parts: string[]): Promise<string> {
479 const [action, ...args] = parts;
480 if (action === "help")
481 return "Commands: create <group>, join <code> <name>, today|week|month [group], members [group], invite [group], kick <name> [group], new-code [group], leave [group], default <group>. Creator leave deletes the whole group. Use a group ID if names repeat.";
482 if (action === "create" || action === "join") {
483 if (args.length !== (action === "create" ? 1 : 2))
484 throw new Error(
485 action === "create"
486 ? "Usage: /who create <group>"
487 : "Usage: /who join <code> <name>",
488 );
489 const mine = action === "create" ? this.displayName : args[1]!;
490 if (!safeName(mine))
491 throw new Error(
492 "Set a displayName using letters, numbers, underscores or hyphens",
493 );
494 const b = await this.request(
495 action === "create" ? "/groups" : "/join",
496 undefined,
497 action === "create"
498 ? { groupName: args[0], displayName: mine }
499 : { inviteCode: args[0]!.toUpperCase(), displayName: mine },
500 );
501 if (
502 typeof b.groupId !== "string" ||
503 !safeName(b.groupName) ||
504 !/^[a-f0-9]{64}$/.test(b.memberKey)
505 )
506 throw new Error("Invalid membership response");
507 const m: Membership = {
508 groupId: b.groupId,
509 groupName: b.groupName,
510 name: mine,
511 memberKey: b.memberKey,
512 since: new Date((await this.host.now()) - 30 * 86400000).toISOString(),
513 relay: this.relay,
514 };
515 await this.host.set(membershipKey(m.groupId), m);
516 if (b.inviteCode)
517 await this.host.set(`invite:${m.groupId}`, b.inviteCode);
518 if (!(await this.host.get("defaultGroup")))
519 await this.host.set("defaultGroup", m.groupId);
520 // Backfill runs after the command dispatch, independent of its budget.
521 this.host.after(0, () => {
522 void this.heartbeat();
523 void this.sync().catch((e) => this.errorStatus(e));
524 });
525 return action === "create"
526 ? `Created ${m.groupName} as ${mine}.\n\n${this.inviteText(m.groupName, b.inviteCode)}`
527 : `Joined ${m.groupName} as ${mine}; the last 30 days will sync in the background.`;
528 }
529 if (action === "default") {
530 if (args.length !== 1) throw new Error("Usage: /who default <group>");
531 const m = await this.selected(args[0]);
532 await this.host.set("defaultGroup", m.groupId);
533 await this.heartbeat();
534 return `Default group: ${m.groupName}`;
535 }
536 if (
537 action &&
538 ![
539 "today",
540 "week",
541 "month",
542 "members",
543 "kick",
544 "new-code",
545 "invite",
546 "leave",
547 ].includes(action)
548 )
549 throw new Error("Unknown command. Run /who help");
550 const required = action === "kick" ? 1 : 0;
551 if (args.length < required || args.length > required + 1)
552 throw new Error("Unexpected arguments. Run /who help");
553 const m = await this.selected(args[required]);
554 if (action === "kick") {
555 return this.removeMember(args[0]!, m.groupId);
556 }
557 if (action === "new-code") {
558 return `New invite code: ${await this.rotateInvite(m.groupId)}`;
559 }
560 if (action === "invite") return this.invitation(m.groupId);
561 if (action === "leave") {
562 await this.request("/leave", m, {});
563 await this.host.remove(membershipKey(m.groupId));
564 await this.host.remove(`cursor:${m.groupId}`);
565 await this.host.remove(`invite:${m.groupId}`);
566 if ((await this.host.get("defaultGroup")) === m.groupId)
567 await this.host.remove("defaultGroup");
568 await this.heartbeat();
569 return `Left ${m.groupName}. If you were its creator, the group and all member data were deleted.`;
570 }
571 if (action === "members") {
572 const b = await this.request("/members?flags=1", m);
573 return (
574 `${m.groupName} (${m.groupId})\n` +
575 b.members
576 .map(
577 (r: any) =>
578 `${r.name}${r.isCreator ? " (creator)" : ""}${r.flagged ? " ⚑ unusually high output" : ""}`,
579 )
580 .join("\n")
581 );
582 }
583 if (action) {
584 const b = await this.request(`/board?period=${action}`, m);
585 return (
586 `${m.groupName} · ${action}\nRank Name Work tokens Gap Last sync\n` +
587 b.rows
588 .map(
589 (r: any) =>
590 `${["🥇", "🥈", "🥉"][r.rank - 1] ?? r.rank} ${r.name} ${r.tokens.toLocaleString("en-US")} ${r.gap.toLocaleString("en-US")} ${r.lastSyncAt ?? "never"}`,
591 )
592 .join("\n")
593 );
594 }
595 const b = await this.request("/presence", m, {
596 sessionId: this.sessionHash,
597 state: this.state,
598 }),
599 now = await this.host.now();
600 return (
601 `${m.groupName}\nName State Time in state Work tokens today\n` +
602 b.members
603 .map(
604 (p: Presence) =>
605 `${p.name} ${p.state} ${Math.max(0, Math.floor((now - Date.parse(p.since)) / 60000))}m ${p.todayTokens.toLocaleString("en-US")}`,
606 )
607 .join("\n")
608 );
609 }
610}
611hooks/pane.ts 424 lines1import {
2 InviteUnavailableError,
3 type DirectMessage,
4 type WhoClient,
5} from "./client";
6type View = "board" | "create" | "join" | "members" | "messages" | "compose";
7export type Period = "today" | "week" | "month";
8export type Snapshot = Awaited<ReturnType<WhoClient["snapshot"]>>;
9export type PaneHost = {
10 invalidate: () => void;
11 open: (focus: boolean) => Promise<unknown>;
12 close: () => Promise<unknown>;
13 defer: (fn: () => void) => void;
14 save: (open: boolean) => Promise<void>;
15};
16export const clean = (text: string) =>
17 text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").slice(0, 200);
18export const compact = (n: number) =>
19 Number.isFinite(n)
20 ? new Intl.NumberFormat("en", {
21 notation: "compact",
22 maximumFractionDigits: 1,
23 }).format(n)
24 : "—";
25export function windowSize(bodyRows: number, hasMessages = false) {
26 return Math.max(1, Math.floor((bodyRows - 11 - (hasMessages ? 1 : 0)) / 3));
27}
28export class Leaderboard {
29 opened = false;
30 loading = false;
31 period: Period = "today";
32 group = "";
33 data: Snapshot | undefined;
34 error = "";
35 notice = "";
36 page = 0;
37 form: View = "board";
38 groupInput = "";
39 codeInput = "";
40 nameInput = "";
41 fresh = true;
42 private generation = 0;
43 selectedMember = "";
44 confirmation: "remove" | "rotate" | "generate-copy" | null = null;
45 actionBusy = false;
46 messages: DirectMessage[] = [];
47 inboxLoading = false;
48 private inboxGeneration = 0;
49 private badgeLoading = false;
50 messageKey = "";
51 messagePage = 0;
52 recipientId = "";
53 private recipientNeedsChoice = false;
54 draft = "";
55 reply: DirectMessage | undefined;
56 private pendingSend:
57 | {
58 id: string;
59 text: string;
60 recipientId: string;
61 group: string;
62 replyTo?: string;
63 }
64 | undefined;
65 get recipients() {
66 return (
67 this.data?.members.filter(
68 (m) =>
69 m.name !== this.data?.me &&
70 typeof m.id === "string" &&
71 m.id.length > 0,
72 ) ?? []
73 );
74 }
75 private reconcileRecipient() {
76 if (
77 this.reply ||
78 this.recipientNeedsChoice ||
79 this.recipients.some((m) => m.id === this.recipientId)
80 )
81 return;
82 if (this.recipientId) {
83 this.recipientId = "";
84 this.recipientNeedsChoice = true;
85 this.error =
86 "The selected friend left. Choose another recipient before sending.";
87 } else this.recipientId = this.recipients[0]?.id ?? "";
88 }
89 chooseRecipient(id: string) {
90 if (this.actionBusy) return;
91 this.recipientId = id;
92 this.recipientNeedsChoice = false;
93 this.host.invalidate();
94 }
95 get currentMessage() {
96 return (
97 this.messages.find((m) => `${m.senderId}:${m.id}` === this.messageKey) ??
98 this.messages[0]
99 );
100 }
101 clearMessages() {
102 this.inboxGeneration++;
103 this.inboxLoading = false;
104 this.messages = [];
105 this.draft = "";
106 this.reply = undefined;
107 this.pendingSend = undefined;
108 this.recipientId = "";
109 this.recipientNeedsChoice = false;
110 this.messageKey = "";
111 this.messagePage = 0;
112 }
113 async pollMessages() {
114 if (!this.opened) return;
115 if (this.form === "board") {
116 if (this.loading || this.badgeLoading || !this.data || !this.group)
117 return;
118 const group = this.group,
119 generation = this.generation;
120 this.badgeLoading = true;
121 try {
122 const count = await this.client.messageCount(group);
123 if (
124 this.opened &&
125 group === this.group &&
126 generation === this.generation &&
127 this.data
128 )
129 this.data.unreadMessages = count;
130 } catch (e) {
131 if (
132 this.opened &&
133 group === this.group &&
134 generation === this.generation
135 )
136 this.error = clean(e instanceof Error ? e.message : String(e));
137 } finally {
138 this.badgeLoading = false;
139 this.host.invalidate();
140 }
141 } else if (this.form === "messages" || this.form === "compose") {
142 await this.refreshMessages();
143 }
144 }
145 async refreshMessages() {
146 if (!this.opened || this.inboxLoading || !this.group) return;
147 const group = this.group,
148 generation = ++this.inboxGeneration;
149 this.inboxLoading = true;
150 try {
151 const messages = await this.client.inbox(group);
152 if (
153 generation !== this.inboxGeneration ||
154 group !== this.group ||
155 !this.opened
156 )
157 return;
158 this.messages = messages;
159 if (this.reply && this.reply.expiresAt <= Date.now())
160 this.reply = undefined;
161 if (this.data) this.data.unreadMessages = messages.length;
162 } catch (e) {
163 if (generation === this.inboxGeneration)
164 this.error = clean(e instanceof Error ? e.message : String(e));
165 } finally {
166 if (generation === this.inboxGeneration) {
167 this.inboxLoading = false;
168 this.messages = this.messages.filter((m) => m.expiresAt > Date.now());
169 if (this.reply && this.reply.expiresAt <= Date.now())
170 this.reply = undefined;
171 this.host.invalidate();
172 }
173 }
174 }
175 compose(message?: DirectMessage) {
176 if (this.actionBusy) return;
177 this.reply = message;
178 this.recipientNeedsChoice = false;
179 this.recipientId = message?.senderId ?? this.recipients[0]?.id ?? "";
180 this.draft = "";
181 this.pendingSend = undefined;
182 this.edit("compose");
183 }
184 send() {
185 if (this.actionBusy) return;
186 const text = this.draft.trim(),
187 recipientId = this.recipientId,
188 group = this.group,
189 replyTo = this.reply?.id;
190 if (!this.reply && !this.recipients.some((m) => m.id === recipientId)) {
191 this.error =
192 "No recipient selected. Refresh members or invite a friend first.";
193 this.host.invalidate();
194 return;
195 }
196 if (!recipientId || !text || text.length > 1000) {
197 this.error = "Choose a friend and write 1–1000 characters.";
198 this.host.invalidate();
199 return;
200 }
201 if (
202 !this.pendingSend ||
203 this.pendingSend.text !== text ||
204 this.pendingSend.recipientId !== recipientId ||
205 this.pendingSend.group !== group ||
206 this.pendingSend.replyTo !== replyTo
207 )
208 this.pendingSend = {
209 id: crypto.randomUUID(),
210 text,
211 recipientId,
212 group,
213 replyTo,
214 };
215 const pending = this.pendingSend;
216 this.action(async () => {
217 await this.client.sendMessage(
218 recipientId,
219 text,
220 pending.id,
221 group,
222 replyTo,
223 );
224 if (this.group !== group || !this.opened) return;
225 this.draft = "";
226 this.pendingSend = undefined;
227 this.reply = undefined;
228 this.inboxGeneration++;
229 this.inboxLoading = false;
230 this.edit("messages");
231 this.notice = "Message sent.";
232 await this.refreshMessages();
233 });
234 }
235 dismiss() {
236 const message = this.currentMessage,
237 group = this.group;
238 if (!message) return;
239 this.action(async () => {
240 await this.client.dismissMessage(message, group);
241 if (this.group !== group) return;
242 this.inboxGeneration++;
243 this.inboxLoading = false;
244 this.messages = this.messages.filter(
245 (m) => m.id !== message.id || m.senderId !== message.senderId,
246 );
247 this.messageKey = "";
248 this.messagePage = 0;
249 if (this.data) this.data.unreadMessages = this.messages.length;
250 this.notice = "Message dismissed.";
251 });
252 }
253 constructor(
254 readonly client: Pick<
255 WhoClient,
256 | "snapshot"
257 | "command"
258 | "copyInvitation"
259 | "removeMember"
260 | "rotateInvite"
261 | "inbox"
262 | "messageCount"
263 | "sendMessage"
264 | "dismissMessage"
265 >,
266 readonly host: PaneHost,
267 ) {}
268 async show(period: Period = this.period, group?: string, focus = true) {
269 this.opened = true;
270 this.period = period;
271 if (group !== undefined && group !== this.group) {
272 this.clearMessages();
273 this.data = undefined;
274 this.group = group;
275 }
276 this.page = 0;
277 this.fresh = true;
278 await this.host.open(focus);
279 await this.host.save(true).catch(() => {});
280 void this.refresh();
281 }
282 async close() {
283 await this.host.close();
284 this.markClosed();
285 }
286 markClosed() {
287 this.opened = false;
288 this.clearMessages();
289 this.generation++;
290 void this.host.save(false).catch(() => {});
291 }
292 change(period: Period, group = this.group) {
293 this.period = period;
294 this.group = group;
295 this.clearMessages();
296 this.page = 0;
297 this.data = undefined;
298 this.error = "";
299 this.generation++;
300 void this.refresh();
301 }
302 async refresh() {
303 const generation = ++this.generation;
304 this.loading = true;
305 this.host.invalidate();
306 try {
307 const data = await this.client.snapshot(
308 this.period,
309 this.group || undefined,
310 );
311 if (generation !== this.generation) return;
312 this.data = data;
313 this.group = data.groupId;
314 this.error = "";
315 this.reconcileRecipient();
316 if (this.form === "messages" || this.form === "compose")
317 void this.refreshMessages();
318 } catch (e) {
319 if (generation === this.generation)
320 this.error = clean(e instanceof Error ? e.message : String(e));
321 } finally {
322 if (generation === this.generation) {
323 this.loading = false;
324 this.host.invalidate();
325 }
326 }
327 }
328 edit(form: View) {
329 if (form !== "messages" && form !== "compose") this.clearMessages();
330 this.form = form;
331 if (form === "messages") void this.refreshMessages();
332 this.confirmation = null;
333 this.notice = "";
334 this.fresh = true;
335 this.host.defer(() => {
336 void this.host.open(true).catch(() => {});
337 });
338 this.host.invalidate();
339 }
340 action(work: () => Promise<void>) {
341 if (this.actionBusy) return;
342 this.actionBusy = true;
343 this.notice = "";
344 this.error = "";
345 this.host.invalidate();
346 this.host.defer(() => {
347 void work()
348 .catch((e) => {
349 this.error = clean(e instanceof Error ? e.message : String(e));
350 })
351 .finally(() => {
352 this.actionBusy = false;
353 this.host.invalidate();
354 });
355 });
356 }
357 copyInvite() {
358 const group = this.group;
359 this.action(async () => {
360 try {
361 await this.client.copyInvitation(group);
362 this.notice = "Invitation copied to clipboard.";
363 } catch (e) {
364 if (!(e instanceof InviteUnavailableError) || this.group !== group)
365 throw e;
366 this.edit("members");
367 this.confirmation = "generate-copy";
368 }
369 });
370 }
371 confirm() {
372 const group = this.group,
373 target = this.selectedMember,
374 action = this.confirmation;
375 this.confirmation = null;
376 this.action(async () => {
377 if (action === "remove") {
378 this.notice = await this.client.removeMember(target, group);
379 this.selectedMember = "";
380 await this.refresh();
381 } else if (action === "generate-copy") {
382 await this.client.rotateInvite(group);
383 await this.client.copyInvitation(group);
384 this.notice = "New invitation copied to clipboard.";
385 } else if (action === "rotate") {
386 await this.client.rotateInvite(group);
387 this.notice = "New invite created. Copy invitation to share it.";
388 }
389 });
390 }
391 submit() {
392 if (this.loading) return;
393 const args =
394 this.form === "create"
395 ? `create ${this.groupInput}`
396 : `join ${this.codeInput} ${this.nameInput}`;
397 this.loading = true;
398 this.host.invalidate();
399 this.host.defer(() => {
400 void this.createOrJoin(args);
401 });
402 }
403 private async createOrJoin(args: string) {
404 try {
405 const result = await this.client.command(args);
406 this.notice = clean(result.split("\n")[0] ?? result);
407 const invite = result.match(/WHO-[A-Z2-7]{4}(?:-[A-Z2-7]{4}){1,7}/)?.[0];
408 if (invite)
409 this.notice = `Invite friends: /who join ${invite} THEIR_NAME`;
410 if (result.startsWith("Created ") || result.startsWith("Joined ")) {
411 this.form = "board";
412 this.group = "";
413 this.data = undefined;
414 await this.refresh();
415 }
416 } catch (e) {
417 this.error = clean(String(e));
418 } finally {
419 this.loading = false;
420 this.host.invalidate();
421 }
422 }
423}
424