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