Skip to content
195 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1/**
2 * Presence and status in the browser, apart from the browser: the status
3 * presets, what "clear after" and "pause notifications" mean as times, how
4 * a person shows (dot and words), and when a tab counts as idle. Pure, so
5 * it is tested on its own; the store and the socket are in
6 * lib/notify-client.ts, the menus in components/presence.tsx.
7 *
8 * Times are the viewer's own: "today" ends at their midnight, "until
9 * tomorrow" is their 9 in the morning. The feed only keeps the instant.
10 */
11import type { PersonStatus, Presence, PresenceEntry } from "@g1t/contracts";
12
13/** A tab with no input for this long is idle; when every tab is, the person shows away. */
14export const IDLE_MS = 10 * 60_000;
15
16export type ClearAfter = "30m" | "1h" | "4h" | "today" | "week" | "never" | "custom";
17
18export const CLEAR_AFTER: { key: ClearAfter; label: string }[] = [
19 { key: "30m", label: "30 minutes" },
20 { key: "1h", label: "1 hour" },
21 { key: "4h", label: "4 hours" },
22 { key: "today", label: "Today" },
23 { key: "week", label: "This week" },
24 { key: "never", label: "Don't clear" },
25 { key: "custom", label: "Choose a time…" },
26];
27
28export type StatusPreset = { emoji: string; text: string; clear: ClearAfter };
29
30/** The statuses offered first: one click each. */
31export const STATUS_PRESETS: StatusPreset[] = [
32 { emoji: "🗓️", text: "In a meeting", clear: "1h" },
33 { emoji: "🚌", text: "Commuting", clear: "30m" },
34 { emoji: "🎯", text: "Focusing", clear: "1h" },
35 { emoji: "🤒", text: "Out sick", clear: "today" },
36 { emoji: "🌴", text: "On vacation", clear: "never" },
37];
38
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.39export type PauseFor = "30m" | "1h" | "2h" | "tomorrow";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers40
41export const PAUSE_FOR: { key: PauseFor; label: string }[] = [
42 { key: "30m", label: "For 30 minutes" },
43 { key: "1h", label: "For 1 hour" },
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.44 { key: "2h", label: "For 2 hours" },
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers45 { key: "tomorrow", label: "Until tomorrow" },
46];
47
48const MINUTE = 60_000;
49
50/** The next midnight after `now`, the viewer's. */
51function nextMidnight(now: Date): Date {
52 const at = new Date(now);
53 at.setHours(24, 0, 0, 0);
54 return at;
55}
56
57/**
58 * When a status set now clears, as an instant; null keeps it until
59 * changed. `custom` is a `datetime-local` value, in the viewer's time.
60 */
61export function clearAtFor(choice: ClearAfter, now: Date, custom?: string | null): string | null {
62 switch (choice) {
63 case "30m":
64 return new Date(now.getTime() + 30 * MINUTE).toISOString();
65 case "1h":
66 return new Date(now.getTime() + 60 * MINUTE).toISOString();
67 case "4h":
68 return new Date(now.getTime() + 240 * MINUTE).toISOString();
69 case "today":
70 return nextMidnight(now).toISOString();
71 case "week": {
72 // The end of Sunday: the coming Monday's midnight.
73 const at = nextMidnight(now);
74 while (at.getDay() !== 1) at.setDate(at.getDate() + 1);
75 return at.toISOString();
76 }
77 case "never":
78 return null;
79 case "custom": {
80 const at = custom ? new Date(custom) : null;
81 return at && Number.isFinite(at.getTime()) && at.getTime() > now.getTime() ? at.toISOString() : null;
82 }
83 }
84}
85
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.86/** When notifications resume: in half an hour, an hour, two hours, or at nine tomorrow morning. */
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers87export function pauseUntil(choice: PauseFor, now: Date): string {
88 if (choice === "30m") return new Date(now.getTime() + 30 * MINUTE).toISOString();
89 if (choice === "1h") return new Date(now.getTime() + 60 * MINUTE).toISOString();
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.90 if (choice === "2h") return new Date(now.getTime() + 120 * MINUTE).toISOString();
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers91 const at = nextMidnight(now);
92 at.setHours(9, 0, 0, 0);
93 return at.toISOString();
94}
95
96/** Whether an instant is still ahead. */
97function ahead(value: string | null | undefined, now: number): boolean {
98 if (!value) return false;
99 const at = Date.parse(value);
100 return Number.isFinite(at) && at > now;
101}
102
103/** A status as it stands now: gone once its time has passed, even before the feed says so. */
104export function liveStatus(status: PersonStatus | null | undefined, now: number): PersonStatus | null {
105 if (!status) return null;
106 return status.clear_at && !ahead(status.clear_at, now) ? null : status;
107}
108
109export function dndOn(entry: Pick<PresenceEntry, "dnd_until"> | null | undefined, now: number): boolean {
110 return ahead(entry?.dnd_until, now);
111}
112
113/** What a person's dot shows. */
114export type Shown = "active" | "away" | "dnd" | "offline";
115
116export function shownAs(entry: Pick<PresenceEntry, "presence" | "dnd_until"> | null | undefined, now: number): Shown {
117 if (!entry || entry.presence === "offline") return "offline";
118 if (dndOn(entry, now)) return "dnd";
119 return entry.presence;
120}
121
122export const SHOWN_LABEL: Record<Shown, string> = {
123 active: "Active",
124 away: "Away",
125 dnd: "Notifications paused",
126 offline: "Offline",
127};
128
129export function presenceLabel(presence: Presence): string {
130 return SHOWN_LABEL[presence];
131}
132
133/**
134 * "until 3:30 PM", "until tomorrow 9:00 AM", "until Mon 9:00 AM" or
135 * "until 12 Oct": short words for when something ends, the viewer's time.
136 */
137export function untilLabel(value: string, now: Date, locale?: string): string {
138 const at = new Date(value);
139 if (!Number.isFinite(at.getTime())) return "";
140 const clock = at.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
141 const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
142 const days = Math.round((day(at) - day(now)) / (24 * 3600 * 1000));
143 // Midnight is the end of the day before: "until tomorrow" reads better as "today".
144 const midnight = at.getHours() === 0 && at.getMinutes() === 0;
145 if (days === 0) return `until ${clock}`;
146 if (days === 1 && midnight) return "until the end of today";
147 if (days === 1) return `until tomorrow ${clock}`;
148 if (days <= 7 && midnight) return `until the end of ${new Date(at.getTime() - 1).toLocaleDateString(locale, { weekday: "long" })}`;
149 if (days < 7) return `until ${at.toLocaleDateString(locale, { weekday: "short" })} ${clock}`;
150 return `until ${at.toLocaleDateString(locale, { day: "numeric", month: "short" })}`;
151}
152
153/** A status in words, for screen readers and hints: "🗓️ In a meeting, until 3:30 PM". */
154export function statusWords(status: PersonStatus, now: Date, locale?: string): string {
155 const text = [status.emoji, status.text].filter(Boolean).join(" ");
156 return status.clear_at ? `${text}, ${untilLabel(status.clear_at, now, locale)}` : text;
157}
158
159/** Whether a tab is idle: no input for `IDLE_MS`. */
160export function isIdle(lastInput: number, now: number): boolean {
161 return now - lastInput >= IDLE_MS;
162}
163
164/** A custom emoji's `:name:`, or null for an ordinary emoji. */
165export function customEmojiName(emoji: string | null | undefined): string | null {
166 const match = emoji ? /^:([a-z0-9_+-]{1,64}):$/.exec(emoji) : null;
167 return match ? match[1]! : null;
168}
169
170/** A `datetime-local` value for an instant, in the viewer's time: what the custom time field starts at. */
171export function localInputValue(at: Date): string {
172 const pad = (n: number) => String(n).padStart(2, "0");
173 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
174}
175
176/**
177 * People by id and by username (lowercased), after a word from the feed:
178 * all of a workspace (`full`), or some who changed. A later word wins.
179 */
180export function mergePeople(
181 people: Record<string, PresenceEntry>,
182 incoming: PresenceEntry[],
183 full: boolean,
184): Record<string, PresenceEntry> {
185 const next: Record<string, PresenceEntry> = { ...people };
186 let changed = false;
187 for (const entry of incoming) {
188 if (!entry?.user_id) continue;
189 const before = next[entry.user_id];
190 if (before && before.at > entry.at) continue;
191 next[entry.user_id] = entry;
192 changed = true;
193 }
194 return changed || full ? next : people;
195}