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