Skip to content
175 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, apart from the Durable Objects that keep them: how
3 * a person's open tabs make them active, away or offline; what a status
4 * may say and when it clears; when Do Not Disturb holds. Pure, so the
5 * rules are tested on their own (presence.test.ts).
6 *
7 * - **Presence** comes from the tabs: none open is `offline`; every tab
8 * idle (no input for a while, as each tab reports), or away set by hand,
9 * is `away`; otherwise `active`.
10 * - **Status** is an emoji and a few words with an optional time it clears
11 * at. Past that time it is gone, whether or not anyone has looked.
12 * - **Source**: a status set by hand is never replaced by a calendar's or
13 * an integration's; theirs replace each other.
14 */
15import type { OwnPresence, PersonStatus, Presence, PresenceChange, PresenceEntry, StatusSource } from "@g1t/contracts";
16
17/** The longest a status's words may be. */
18export const MAX_STATUS_TEXT = 100;
19/** The longest an emoji may be: a flag or a family is several code points; `:custom-name:` fits too. */
20const MAX_EMOJI = 40;
21/** The furthest ahead a status or Do Not Disturb may be set to end: a year. */
22export const MAX_AHEAD_MS = 366 * 24 * 3600 * 1000;
23/** How long the last tab may be gone before its person shows offline: a reload or a workspace switch is not leaving. */
24export const OFFLINE_GRACE_MS = 30_000;
25
26const SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"];
27
28/** A tab as presence sees it. */
29export type PresenceTab = { idle: boolean };
30
31/** What the feed keeps of its person's presence. */
32export type Kept = {
33 away_manual: boolean;
34 dnd_until: string | null;
35 status: PersonStatus | null;
36};
37
38export const NOTHING_KEPT: Kept = { away_manual: false, dnd_until: null, status: null };
39
40/** Presence from the open tabs and whether they set themselves away. */
41export function presenceOf(tabs: PresenceTab[], awayManual: boolean): Presence {
42 if (tabs.length === 0) return "offline";
43 if (awayManual) return "away";
44 return tabs.every((tab) => tab.idle) ? "away" : "active";
45}
46
47function time(value: string | null | undefined): number | null {
48 if (typeof value !== "string" || !value) return null;
49 const at = Date.parse(value);
50 return Number.isFinite(at) ? at : null;
51}
52
53/** A time to end at, checked: in the future and within a year; null when it is not one. */
54export function futureTime(value: unknown, now: number): string | null {
55 const at = time(typeof value === "string" ? value : null);
56 if (at == null || at <= now || at > now + MAX_AHEAD_MS) return null;
57 return new Date(at).toISOString();
58}
59
60/** Whether Do Not Disturb holds at `now`. */
61export function dndOn(dndUntil: string | null | undefined, now: number): boolean {
62 const at = time(dndUntil);
63 return at != null && at > now;
64}
65
66/** A status as it stands at `now`: gone once its time has passed. */
67export function liveStatus(status: PersonStatus | null | undefined, now: number): PersonStatus | null {
68 if (!status) return null;
69 const clear = time(status.clear_at);
70 return clear != null && clear <= now ? null : status;
71}
72
73/** What is kept, with everything that has run out taken away. */
74export function current(kept: Kept, now: number): Kept {
75 return {
76 away_manual: kept.away_manual,
77 dnd_until: dndOn(kept.dnd_until, now) ? kept.dnd_until : null,
78 status: liveStatus(kept.status, now),
79 };
80}
81
82/** A status as sent, checked and trimmed; null when it says nothing. */
83export function cleanStatus(value: unknown, now: number): PersonStatus | null {
84 if (!value || typeof value !== "object") return null;
85 const s = value as Record<string, unknown>;
86 const text = typeof s.text === "string" ? s.text.replace(/\s+/g, " ").trim().slice(0, MAX_STATUS_TEXT) : "";
87 const emoji = typeof s.emoji === "string" ? s.emoji.trim().slice(0, MAX_EMOJI) : "";
88 if (!text && !emoji) return null;
89 const source = SOURCES.find((known) => known === s.source) ?? "manual";
90 // A time already past, or too far off, is no time: kept until changed only when none was given.
91 const clearAt = s.clear_at == null ? null : futureTime(s.clear_at, now);
92 if (s.clear_at != null && clearAt == null) return null;
93 return { emoji: emoji || null, text, clear_at: clearAt, source, set_at: new Date(now).toISOString() };
94}
95
96/**
97 * What is kept after a change. A status from a calendar or an integration
98 * never replaces one set by hand, and clearing from one of them clears only
99 * a status of theirs.
100 */
101export function applyChange(before: Kept, change: PresenceChange | null | undefined, now: number): Kept {
102 const kept = current(before, now);
103 const c = (change && typeof change === "object" ? change : {}) as PresenceChange;
104 const next: Kept = { ...kept };
105 if (typeof c.away === "boolean") next.away_manual = c.away;
106 if ("dnd_until" in c) next.dnd_until = c.dnd_until == null ? null : futureTime(c.dnd_until, now);
107 if ("status" in c) {
108 const source: StatusSource = (c.status && SOURCES.find((known) => known === c.status!.source)) || "manual";
109 const byHand = kept.status?.source === "manual";
110 if (source === "manual" || !byHand) next.status = c.status == null ? null : cleanStatus({ ...c.status, source }, now);
111 }
112 return next;
113}
114
115/** The next time something kept runs out, in ms; null when nothing will. */
116export function nextExpiry(kept: Kept, now: number): number | null {
117 const times = [time(kept.status?.clear_at), time(kept.dnd_until)].filter((at): at is number => at != null && at > now);
118 return times.length ? Math.min(...times) : null;
119}
120
121/** How a person shows to others. */
122export function entryOf(person: { user_id: string; username: string }, presence: Presence, kept: Kept, now: number): PresenceEntry {
123 const live = current(kept, now);
124 return { user_id: person.user_id, username: person.username, presence, dnd_until: live.dnd_until, status: live.status, at: now };
125}
126
127export function ownOf(entry: PresenceEntry, kept: Kept): OwnPresence {
128 return { ...entry, away_manual: kept.away_manual };
129}
130
131/** Whether two entries would show the same, whenever they were made. */
132export function sameEntry(a: PresenceEntry | null | undefined, b: PresenceEntry | null | undefined): boolean {
133 if (!a || !b) return a === b;
134 return (
135 a.presence === b.presence &&
136 a.username === b.username &&
137 a.dnd_until === b.dnd_until &&
138 JSON.stringify(a.status) === JSON.stringify(b.status)
139 );
140}
141
142/** An entry as it stands at `now`: Do Not Disturb and a status past their time are gone. */
143export function liveEntry(entry: PresenceEntry, now: number): PresenceEntry {
144 const status = liveStatus(entry.status, now);
145 const dnd = dndOn(entry.dnd_until, now) ? entry.dnd_until : null;
146 return status === entry.status && dnd === entry.dnd_until ? entry : { ...entry, status, dnd_until: dnd };
147}
148
149/** What is kept, read back; nothing for anything that is not it. */
150export function readKept(json: string | null | undefined): Kept {
151 if (!json) return { ...NOTHING_KEPT };
152 try {
153 const value = JSON.parse(json) as Partial<Kept>;
154 return {
155 away_manual: value.away_manual === true,
156 dnd_until: typeof value.dnd_until === "string" ? value.dnd_until : null,
157 status: value.status && typeof value.status === "object" ? (value.status as PersonStatus) : null,
158 };
159 } catch {
160 return { ...NOTHING_KEPT };
161 }
162}
163
164/** Workspace slugs as the site sent them: lowercased, distinct, at most 200. */
165export function cleanWorkspaces(value: unknown): string[] {
166 if (!Array.isArray(value)) return [];
167 const out = new Set<string>();
168 for (const slug of value) {
169 if (typeof slug !== "string") continue;
170 const clean = slug.trim().toLowerCase();
171 if (/^[a-z0-9][a-z0-9-]{0,38}$/.test(clean)) out.add(clean);
172 if (out.size >= 200) break;
173 }
174 return [...out];
175}