Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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