Skip to content
480 linesCodeBlameRaw
1/**
2 * People's presence and status, wherever a person shows: the dot on their
3 * avatar (active, away, notifications paused), the emoji after their name,
4 * and, for yourself, the menu and dialog that set them. Live from the feed
5 * socket (lib/notify-client.ts); the rules are in lib/presence.ts.
6 *
7 * Agents keep their own dots (idle, working, out of budget: chat/marks.tsx);
8 * nothing here draws one for an agent.
9 */
10import { BellOff, BellRing, Moon, Smile, Sun, X } from "lucide-react";
11import { type ReactNode, useEffect, useState } from "react";
12
13import type { PersonStatus, PresenceEntry } from "@g1t/contracts";
14
15import { EmojiGlyph } from "./emoji/render";
16import { EmojiPickerPopover } from "./emoji/picker";
17import { useEmojiContext } from "./emoji/context";
18
19import { Button } from "./ui/button";
20import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
21import {
22 DropdownMenuItem,
23 DropdownMenuSeparator,
24 DropdownMenuSub,
25 DropdownMenuSubContent,
26 DropdownMenuSubTrigger,
27} from "./ui/dropdown-menu";
28import { Hint } from "./ui/hint";
29import { Input } from "./ui/input";
30import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
31import { cn } from "../lib/cn";
32import { setPresence, useOwnPresence, usePresenceOf } from "../lib/notify-client";
33import {
34 CLEAR_AFTER,
35 type ClearAfter,
36 PAUSE_FOR,
37 SHOWN_LABEL,
38 STATUS_PRESETS,
39 type Shown,
40 clearAtFor,
41 dndOn,
42 liveStatus,
43 localInputValue,
44 pauseUntil,
45 shownAs,
46 statusWords,
47 untilLabel,
48} from "../lib/presence";
49
50/** Re-renders every half minute, so a status or a pause past its time goes without a word from the feed. */
51function useMinute(): number {
52 const [now, setNow] = useState(() => Date.now());
53 useEffect(() => {
54 const timer = setInterval(() => setNow(Date.now()), 30_000);
55 return () => clearInterval(timer);
56 }, []);
57 return now;
58}
59
60const DOT: Record<Exclude<Shown, "offline">, string> = {
61 active: "bg-success",
62 // Away: a ring, the dot's colour gone out of it.
63 away: "bg-[var(--presence-ring,var(--color-bg))] shadow-[inset_0_0_0_2px_var(--color-faint)]",
64 // Paused: amber, with a bar across, as a "do not enter" sign.
65 dnd: "bg-warn",
66};
67
68/**
69 * The dot itself. `ring` is the colour behind it, so it sits cut out of
70 * the avatar's corner; nothing shows for someone offline, or not known.
71 */
72export function PresenceDot({ entry, size = 10, ring = "var(--color-bg)", className }: { entry: PresenceEntry | null; size?: number; ring?: string; className?: string }) {
73 const now = useMinute();
74 const shown = shownAs(entry, now);
75 if (shown === "offline") return null;
76 return (
77 <span
78 className={cn("pointer-events-none flex items-center justify-center rounded-full", DOT[shown], className)}
79 style={{ width: size, height: size, boxShadow: `0 0 0 2px ${ring}`, ["--presence-ring" as string]: ring }}
80 >
81 {shown === "dnd" && <span className="h-[2px] w-1/2 rounded-full bg-bg" aria-hidden="true" />}
82 <span className="sr-only">{SHOWN_LABEL[shown]}</span>
83 </span>
84 );
85}
86
87/**
88 * An avatar with the person's dot in its corner. `person` names them by
89 * id or username; agents and anyone unknown get the avatar alone.
90 */
91export function WithPresence({
92 person,
93 size,
94 ring,
95 children,
96 className,
97}: {
98 person: { id?: string | null; username?: string | null } | null;
99 /** The avatar's size: the dot is a third of it, within limits. */
100 size: number;
101 ring?: string;
102 children: ReactNode;
103 className?: string;
104}) {
105 const entry = usePresenceOf(person);
106 const dot = Math.max(8, Math.min(14, Math.round(size * 0.34)));
107 return (
108 <span className={cn("relative inline-flex shrink-0", className)}>
109 {children}
110 <PresenceDot entry={entry} size={dot} ring={ring} className="absolute -right-0.5 -bottom-0.5" />
111 </span>
112 );
113}
114
115/**
116 * A status's emoji after a name, its words in a hint. `inert` inside a link
117 * or a button: no hint and no focus of its own, the words for screen
118 * readers only.
119 */
120export function StatusEmoji({ status, size = 14, className, inert = false }: { status: PersonStatus | null | undefined; size?: number; className?: string; inert?: boolean }) {
121 const now = useMinute();
122 const { byName, usercontent } = useEmojiContext();
123 const live = liveStatus(status, now);
124 if (!live) return null;
125 const words = statusWords(live, new Date(now));
126 const glyph = live.emoji ? (
127 <EmojiGlyph emoji={live.emoji} byName={byName} usercontent={usercontent} size={size} />
128 ) : (
129 <Smile size={size} className="text-faint" aria-hidden="true" />
130 );
131 if (inert) {
132 return (
133 <span className={cn("inline-flex shrink-0 items-center", className)}>
134 <span aria-hidden="true" className="inline-flex">
135 {glyph}
136 </span>
137 <span className="sr-only">, {words}</span>
138 </span>
139 );
140 }
141 return (
142 <Hint label={words}>
143 <span className={cn("inline-flex shrink-0 items-center", className)} tabIndex={0} aria-label={`Status: ${words}`}>
144 {glyph}
145 </span>
146 </Hint>
147 );
148}
149
150/** A person's status emoji, live, by id or username. */
151export function PersonStatusEmoji({
152 person,
153 size,
154 className,
155 inert,
156}: {
157 person: { id?: string | null; username?: string | null };
158 size?: number;
159 className?: string;
160 inert?: boolean;
161}) {
162 const entry = usePresenceOf(person);
163 return <StatusEmoji status={entry?.status} size={size} className={className} inert={inert} />;
164}
165
166/**
167 * One short line: their status ("🗓️ In a meeting") or, without one, how
168 * they show ("Active", "Away", "Notifications paused"). Nothing while it
169 * is not known, or when they are offline with nothing to say.
170 */
171export function PersonStatusLine({ person, className }: { person: { id?: string | null; username?: string | null }; className?: string }) {
172 const entry = usePresenceOf(person);
173 const now = useMinute();
174 const { byName, usercontent } = useEmojiContext();
175 if (!entry) return null;
176 const status = liveStatus(entry.status, now);
177 const shown = shownAs(entry, now);
178 if (!status && shown === "offline") return null;
179 return (
180 <p className={cn("flex min-w-0 items-center gap-1.5", className)}>
181 {status ? (
182 <>
183 {status.emoji && <EmojiGlyph emoji={status.emoji} byName={byName} usercontent={usercontent} size={12} />}
184 <span className="truncate">{status.text || SHOWN_LABEL[shown]}</span>
185 </>
186 ) : (
187 <span className="truncate">{SHOWN_LABEL[shown]}</span>
188 )}
189 </p>
190 );
191}
192
193/**
194 * A person's presence and status in a line or two, for their card:
195 * "Active", "Away", "Notifications paused until 3:00 PM", and "🗓️ In a
196 * meeting, until 3:30 PM".
197 */
198export function PresenceSummary({ person, className }: { person: { id?: string | null; username?: string | null }; className?: string }) {
199 const entry = usePresenceOf(person);
200 const now = useMinute();
201 const { byName, usercontent } = useEmojiContext();
202 if (!entry) return null;
203 const shown = shownAs(entry, now);
204 const status = liveStatus(entry.status, now);
205 return (
206 <div className={cn("space-y-1.5", className)}>
207 {status && (
208 <p className="flex items-center gap-2 text-fg-soft">
209 {status.emoji ? <EmojiGlyph emoji={status.emoji} byName={byName} usercontent={usercontent} size={14} /> : <Smile size={13} className="text-faint" />}
210 <span className="min-w-0 truncate">
211 {status.text}
212 {status.clear_at && <span className="text-faint"> · {untilLabel(status.clear_at, new Date(now))}</span>}
213 </span>
214 </p>
215 )}
216 <p className="flex items-center gap-2">
217 <span className="flex size-[13px] items-center justify-center">
218 {shown === "offline" ? <span className="size-2 rounded-full shadow-[inset_0_0_0_1.5px_var(--color-line-strong)]" /> : <PresenceDot entry={entry} size={9} ring="transparent" />}
219 </span>
220 {SHOWN_LABEL[shown]}
221 {shown === "dnd" && entry.dnd_until && <span className="text-faint">{untilLabel(entry.dnd_until, new Date(now))}</span>}
222 </p>
223 </div>
224 );
225}
226
227// ── Your own ─────────────────────────────────────────────────────────────
228
229/**
230 * The account menu's part: your status (set, change or clear it), away by
231 * hand, and pausing notifications. `onEdit` opens the status dialog, which
232 * lives outside the menu so the menu can close.
233 */
234export function OwnPresenceItems({ onEdit, className }: { onEdit: () => void; className?: string }) {
235 const me = useOwnPresence();
236 const now = useMinute();
237 const { byName, usercontent } = useEmojiContext();
238 const status = liveStatus(me?.status, now);
239 const paused = dndOn(me, now);
240 const away = me?.away_manual === true;
241 return (
242 <>
243 <DropdownMenuItem className={cn("gap-2.5 px-2.5", className)} onSelect={onEdit}>
244 {status?.emoji ? (
245 <span className="flex size-4 items-center justify-center">
246 <EmojiGlyph emoji={status.emoji} byName={byName} usercontent={usercontent} size={15} />
247 </span>
248 ) : (
249 <Smile />
250 )}
251 <span className="min-w-0 grow">
252 <span className={cn("block truncate", status ? "text-fg" : "")}>{status ? status.text || "Status set" : "Set a status"}</span>
253 {status?.clear_at && <span className="block truncate text-xs text-faint">{untilLabel(status.clear_at, new Date(now))}</span>}
254 </span>
255 </DropdownMenuItem>
256 {status && (
257 <DropdownMenuItem
258 className={cn("gap-2.5 px-2.5", className)}
259 onSelect={(event) => {
260 event.preventDefault();
261 void setPresence({ status: null }, { status: null });
262 }}
263 >
264 <X />
265 Clear status
266 </DropdownMenuItem>
267 )}
268 <DropdownMenuItem
269 className={cn("gap-2.5 px-2.5", className)}
270 onSelect={(event) => {
271 event.preventDefault();
272 void setPresence({ away: !away }, { away_manual: !away, presence: away ? "active" : "away" });
273 }}
274 >
275 {away ? <Sun /> : <Moon />}
276 {away ? "Set yourself active" : "Set yourself away"}
277 </DropdownMenuItem>
278 {paused ? (
279 <DropdownMenuItem
280 className={cn("gap-2.5 px-2.5", className)}
281 onSelect={(event) => {
282 event.preventDefault();
283 void setPresence({ dnd_until: null }, { dnd_until: null });
284 }}
285 >
286 <BellRing />
287 <span className="grow">Resume notifications</span>
288 {me?.dnd_until && <span className="text-xs text-faint">{untilLabel(me.dnd_until, new Date(now)).replace(/^until /, "paused until ")}</span>}
289 </DropdownMenuItem>
290 ) : (
291 <DropdownMenuSub>
292 <DropdownMenuSubTrigger className={cn("gap-2.5 px-2.5", className)}>
293 <BellOff />
294 Pause notifications
295 </DropdownMenuSubTrigger>
296 <DropdownMenuSubContent>
297 {PAUSE_FOR.map((choice) => (
298 <DropdownMenuItem
299 key={choice.key}
300 onSelect={() => {
301 const until = pauseUntil(choice.key, new Date());
302 void setPresence({ dnd_until: until }, { dnd_until: until });
303 }}
304 >
305 {choice.label}
306 </DropdownMenuItem>
307 ))}
308 <DropdownMenuSeparator />
309 <p className="max-w-52 px-2 py-1 text-xs leading-snug text-faint">Counts still move. Nothing toasts or pushes until then.</p>
310 </DropdownMenuSubContent>
311 </DropdownMenuSub>
312 )}
313 </>
314 );
315}
316
317/** The dot on your own avatar in the rail: how others see you. */
318export function OwnPresenceDot({ ring, className }: { ring: string; className?: string }) {
319 const me = useOwnPresence();
320 // Until the feed says, you are here: you are looking at the page.
321 const entry: PresenceEntry = me ?? { user_id: "", username: "", presence: "active", dnd_until: null, status: null, at: 0 };
322 return <PresenceDot entry={entry} size={12} ring={ring} className={className} />;
323}
324
325/**
326 * Setting your status: an emoji and a few words, or one of the presets,
327 * and when it clears: in 30 minutes, an hour, four hours, at the end of
328 * today or the week, never, or at a time you choose.
329 */
330export function StatusDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
331 const me = useOwnPresence();
332 const { byName, usercontent } = useEmojiContext();
333 const [emoji, setEmoji] = useState<string | null>(null);
334 const [text, setText] = useState("");
335 const [clear, setClear] = useState<ClearAfter>("today");
336 const [custom, setCustom] = useState("");
337 const [saving, setSaving] = useState(false);
338 const [error, setError] = useState<string | null>(null);
339
340 // Opened: starts from the status you have now.
341 useEffect(() => {
342 if (!open) return;
343 const status = liveStatus(me?.status, Date.now());
344 setEmoji(status?.emoji ?? null);
345 setText(status?.text ?? "");
346 setClear(status ? (status.clear_at ? "custom" : "never") : "today");
347 setCustom(status?.clear_at ? localInputValue(new Date(status.clear_at)) : localInputValue(new Date(Date.now() + 60 * 60_000)));
348 setError(null);
349 // eslint-disable-next-line react-hooks/exhaustive-deps
350 }, [open]);
351
352 const save = async (next: { emoji: string | null; text: string; clear: ClearAfter }) => {
353 const words = next.text.trim();
354 if (!words && !next.emoji) {
355 setError("Say something, or pick an emoji.");
356 return;
357 }
358 const clearAt = clearAtFor(next.clear, new Date(), custom);
359 if (next.clear === "custom" && !clearAt) {
360 setError("Choose a time that is still ahead.");
361 return;
362 }
363 setSaving(true);
364 const saved = await setPresence({ status: { emoji: next.emoji, text: words, clear_at: clearAt } });
365 setSaving(false);
366 if (!saved) {
367 setError("Your status didn't save. Try again.");
368 return;
369 }
370 onOpenChange(false);
371 };
372
373 const clearNow = async () => {
374 setSaving(true);
375 await setPresence({ status: null }, { status: null });
376 setSaving(false);
377 onOpenChange(false);
378 };
379
380 return (
381 <Dialog open={open} onOpenChange={onOpenChange}>
382 <DialogContent className="max-w-md">
383 <DialogHeader>
384 <DialogTitle>Set a status</DialogTitle>
385 <DialogDescription>Everyone in your workspaces sees it beside your name.</DialogDescription>
386 </DialogHeader>
387 <form
388 className="grid gap-4"
389 onSubmit={(event) => {
390 event.preventDefault();
391 void save({ emoji, text, clear });
392 }}
393 >
394 <div className="flex items-center gap-2">
395 <EmojiPickerPopover onPick={(picked) => setEmoji(picked)} side="bottom" align="start">
396 <Button
397 type="button"
398 aria-label={emoji ? `Emoji: ${emoji}. Change it` : "Choose an emoji"}
399 variant="outline"
400 size="icon"
401 className="bg-bg hover:bg-raised"
402 >
403 {emoji ? <EmojiGlyph emoji={emoji} byName={byName} usercontent={usercontent} size={18} /> : <Smile size={17} className="text-faint" />}
404 </Button>
405 </EmojiPickerPopover>
406 <Input
407 value={text}
408 onChange={(event) => setText(event.target.value)}
409 maxLength={100}
410 placeholder="What's your status?"
411 aria-label="Status"
412 autoComplete="off"
413 data-1p-ignore
414 autoFocus
415 />
416 </div>
417 <div>
418 <p className="mb-1.5 text-xs font-medium text-faint">Suggestions</p>
419 <ul className="space-y-px">
420 {STATUS_PRESETS.map((preset) => (
421 <li key={preset.text}>
422 <button
423 type="button"
424 onClick={() => {
425 setEmoji(preset.emoji);
426 setText(preset.text);
427 setClear(preset.clear);
428 setError(null);
429 }}
430 className="flex h-9 w-full items-center gap-3 rounded-md px-2 text-left text-sm transition-colors hover:bg-raised"
431 >
432 <span className="w-5 text-center text-base leading-none">{preset.emoji}</span>
433 <span className="grow text-fg/90">{preset.text}</span>
434 <span className="text-xs text-faint">{CLEAR_AFTER.find((c) => c.key === preset.clear)?.label}</span>
435 </button>
436 </li>
437 ))}
438 </ul>
439 </div>
440 <div className="grid gap-2 sm:grid-cols-[auto_1fr] sm:items-center sm:gap-3">
441 <label htmlFor="status-clear" className="text-sm text-muted">
442 Clear after
443 </label>
444 <Select value={clear} onValueChange={(value) => setClear(value as ClearAfter)}>
445 <SelectTrigger id="status-clear" size="sm">
446 <SelectValue />
447 </SelectTrigger>
448 <SelectContent>
449 {CLEAR_AFTER.map((choice) => (
450 <SelectItem key={choice.key} value={choice.key}>
451 {choice.label}
452 </SelectItem>
453 ))}
454 </SelectContent>
455 </Select>
456 {clear === "custom" && (
457 <>
458 <label htmlFor="status-clear-at" className="text-sm text-muted">
459 At
460 </label>
461 <Input id="status-clear-at" type="datetime-local" value={custom} onChange={(event) => setCustom(event.target.value)} />
462 </>
463 )}
464 </div>
465 {error && <p className="text-sm text-danger">{error}</p>}
466 <DialogFooter>
467 {liveStatus(me?.status, Date.now()) && (
468 <Button type="button" variant="outline" disabled={saving} onClick={() => void clearNow()}>
469 Clear status
470 </Button>
471 )}
472 <Button type="submit" variant="accent" disabled={saving}>
473 {saving ? "Saving…" : "Save"}
474 </Button>
475 </DialogFooter>
476 </form>
477 </DialogContent>
478 </Dialog>
479 );
480}