Skip to content

g1t/apps/web/app/lib/inbox.ts

313 lines13,426 bytesCodeBlame
1/**
2 * The inbox's tabs, reasons, times and actions, shared by the panel in the
3 * top bar (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx),
4 * and how people subscribe to threads and watch repositories
5 * (components/notifications.tsx). The events service keeps the items and
6 * decides who is told of what.
7 */
8import type {
9 InboxCounts,
10 InboxItem,
11 InboxMarkArgs,
12 InboxReason,
13 InboxSeverity,
14 InboxView,
15 ThreadSubscription,
16 WatchEvent,
17 WatchLevel,
18} from "@g1t/contracts";
19
20// The contracts' lists, as types only, so this file runs under `node --test`.
21// Typed by the contract: a reason or kind added there must be added here.
22const INBOX_REASONS: InboxReason[] = [
23 "agent",
24 "review_requested",
25 "assign",
26 "mention",
27 "team_mention",
28 "ci_activity",
29 "security_alert",
30 "state_change",
31 "author",
32 "comment",
33 "manual",
34 "subscribed",
35];
36const WATCH_EVENTS: WatchEvent[] = ["issues", "pulls", "deployments", "security"];
37
38export type InboxTab = "all" | "needs" | "error" | "success" | "info";
39
40/** The tabs, in order, and the severity each shows. */
41export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [
42 { tab: "all", label: "All", severity: null },
43 // Warnings are what is waiting on a person: an agent, or a review asked of them.
44 { tab: "needs", label: "Needs you", severity: "warning" },
45 { tab: "error", label: "Errors", severity: "error" },
46 { tab: "success", label: "Success", severity: "success" },
47 { tab: "info", label: "Info", severity: "info" },
48];
49
50/** The words on an item's badge. */
51export const SEVERITY_LABEL: Record<InboxSeverity, string> = {
52 error: "Error",
53 warning: "Needs you",
54 success: "Success",
55 info: "Info",
56};
57
58export function inboxTab(value: string | null | undefined): InboxTab {
59 return INBOX_TABS.find((entry) => entry.tab === value)?.tab ?? "all";
60}
61
62export function severityOf(tab: InboxTab): InboxSeverity | null {
63 return INBOX_TABS.find((entry) => entry.tab === tab)?.severity ?? null;
64}
65
66export function inboxView(value: string | null | undefined): InboxView {
67 return value === "saved" || value === "done" ? value : "inbox";
68}
69
70/** What is unread under a tab. */
71export function tabCount(counts: InboxCounts | null | undefined, tab: InboxTab): number {
72 if (!counts) return 0;
73 const severity = severityOf(tab);
74 return severity ? counts[severity] : counts.unread;
75}
76
77/** The bell's number: up to 99, then "99+". Empty when nothing is unread. */
78export function bellCount(unread: number | null | undefined): string {
79 if (!unread || unread < 1) return "";
80 return unread > 99 ? "99+" : String(unread);
81}
82
83/** What an empty tab says. */
84export function emptyFor(tab: InboxTab, view: InboxView = "inbox"): { title: string; detail: string } {
85 if (view === "saved") return { title: "Nothing saved", detail: "Save an item to keep it here after it is done." };
86 if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." };
87 switch (tab) {
88 case "needs":
89 return { title: "Nothing needs you", detail: "When an agent is waiting on you, or someone asks for your review, it shows up here first." };
90 case "error":
91 return { title: "No failures", detail: "Failed checks and workflows on your work show up here." };
92 case "success":
93 return { title: "Nothing new landed", detail: "Merges, approvals and finished agent work show up here." };
94 case "info":
95 return { title: "No mentions or comments", detail: "Mentions of you and comments on your work show up here." };
96 default:
97 return { title: "You're all caught up", detail: "What needs you, or what you follow, shows up here as it happens." };
98 }
99}
100
101const MINUTE = 60_000;
102const HOUR = 60 * MINUTE;
103const DAY = 24 * HOUR;
104
105/** When, in a few characters: "just now", "15m ago", "3h ago", "Yesterday", "4d ago", "Sep 30". */
106export function whenShort(at: string, now: number): string {
107 const time = Date.parse(at);
108 const elapsed = Math.max(0, now - time);
109 if (elapsed < MINUTE) return "just now";
110 if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)}m ago`;
111 if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)}h ago`;
112 if (elapsed < 2 * DAY) return "Yesterday";
113 if (elapsed < 7 * DAY) return `${Math.floor(elapsed / DAY)}d ago`;
114 return new Date(time).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
115}
116
117/** How long an item can be snoozed for. */
118export const SNOOZES = [
119 { key: "3h", label: "3 hours", ms: 3 * HOUR },
120 { key: "tomorrow", label: "Tomorrow", ms: DAY },
121 { key: "week", label: "Next week", ms: 7 * DAY },
122] as const;
123
124export function snoozeUntil(key: string | null | undefined, now: number): string | null {
125 const snooze = SNOOZES.find((entry) => entry.key === key);
126 return snooze ? new Date(now + snooze.ms).toISOString() : null;
127}
128
129/** What the inbox forms post, by `intent`. */
130const MARKS: Record<string, InboxMarkArgs["mark"]> = {
131 read: "read",
132 unread: "unread",
133 done: "done",
134 undone: "undone",
135 save: "save",
136 unsave: "unsave",
137 snooze: "snooze",
138};
139
140/**
141 * A posted inbox form as the mark it asks for: one item (`id`), or every
142 * item (`intent` `read_all`, optionally for one tab). Null when it asks for
143 * nothing the inbox does.
144 */
145export function markFromForm(form: FormData, now: number): InboxMarkArgs | null {
146 const intent = String(form.get("intent") ?? "");
147 if (intent === "read_all") {
148 return { mark: "read", all: true, severity: severityOf(inboxTab(String(form.get("tab") ?? ""))) };
149 }
150 const mark = MARKS[intent];
151 const id = String(form.get("id") ?? "").trim();
152 if (!mark || !id) return null;
153 if (mark === "snooze") {
154 const until = snoozeUntil(String(form.get("for") ?? ""), now);
155 return until ? { mark, ids: [id], until } : null;
156 }
157 return { mark, ids: [id] };
158}
159
160/**
161 * Mission control's Needs you card, from unread items: what an agent is
162 * waiting on first, then failures, newest first within each; `max` of them,
163 * and how many there are in all.
164 */
165export function needsYou(items: InboxItem[], max: number): { items: InboxItem[]; total: number } {
166 const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1);
167 const needs = items
168 .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error"))
169 .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt));
170 return { items: needs.slice(0, max), total: needs.length };
171}
172
173/** Whether an item is still unread. */
174export function isUnread(item: Pick<InboxItem, "readAt">): boolean {
175 return item.readAt == null;
176}
177
178/** Why someone was told, in a few quiet words on the card. */
179export const REASON_LABEL: Record<InboxReason, string> = {
180 agent: "agent waiting",
181 review_requested: "review requested",
182 assign: "assigned",
183 mention: "mentioned",
184 team_mention: "team mentioned",
185 ci_activity: "CI activity",
186 security_alert: "security alert",
187 state_change: "state changed",
188 author: "your work",
189 comment: "commented",
190 manual: "subscribed",
191 subscribed: "watching",
192};
193
194/** The reason filter on /inbox, from the address: null for any reason. */
195export function inboxReason(value: string | null | undefined): InboxReason | null {
196 return INBOX_REASONS.find((reason) => reason === value) ?? null;
197}
198
199/** The reason filter's choices, in the order reasons rank. */
200export const REASON_FILTERS: { reason: InboxReason | null; label: string }[] = [
201 { reason: null, label: "Any reason" },
202 ...INBOX_REASONS.map((reason) => ({ reason, label: REASON_LABEL[reason].replace(/^./, (c) => c.toUpperCase()) })),
203];
204
205/** How much has happened on a thread, when more than one thing has: "3 updates". */
206export function updatesLabel(count: number | null | undefined): string {
207 return count && count > 1 ? `${count} updates` : "";
208}
209
210/** The ways to watch a repository, in the order the menu shows them. */
211export const WATCH_CHOICES: { level: WatchLevel; label: string; detail: string }[] = [
212 { level: "participating", label: "Participating and @mentions", detail: "Only what you take part in, or are mentioned in." },
213 { level: "all", label: "All activity", detail: "Every issue and pull request, and every deployment." },
214 { level: "ignore", label: "Ignore", detail: "Nothing at all, not even a mention." },
215 { level: "custom", label: "Custom", detail: "What you take part in, and the kinds you choose." },
216];
217
218/** The kinds a custom watch can follow, with their names for people. */
219export const WATCH_EVENT_LABEL: Record<WatchEvent, string> = {
220 issues: "Issues",
221 pulls: "Pull requests",
222 deployments: "Deployments",
223 security: "Security alerts",
224};
225
226/** The Watch button's words for how someone watches. */
227export function watchLabel(level: WatchLevel | null | undefined): string {
228 switch (level) {
229 case "all":
230 return "Watching";
231 case "custom":
232 return "Watching some";
233 case "ignore":
234 return "Ignoring";
235 default:
236 return "Watch";
237 }
238}
239
240/**
241 * A posted watch form as the level and kinds it asks for: `level`, and for
242 * `custom` the `event` fields checked. A custom watch with nothing checked
243 * is participating. Null when the level is not one.
244 */
245export function watchFromForm(form: FormData): { level: WatchLevel; events: WatchEvent[] } | null {
246 const level = String(form.get("level") ?? "");
247 if (!["participating", "all", "ignore", "custom"].includes(level)) return null;
248 if (level !== "custom") return { level: level as WatchLevel, events: [] };
249 const checked = form.getAll("event").map(String);
250 const events = WATCH_EVENTS.filter((event) => checked.includes(event));
251 return events.length > 0 ? { level: "custom", events } : { level: "participating", events: [] };
252}
253
254/** The line under the subscribe button: whether, and why. */
255export function subscriptionLine(subscription: ThreadSubscription | null | undefined, kind: "issue" | "pull"): string {
256 const thing = kind === "pull" ? "pull request" : "issue";
257 if (!subscription) return `Subscribe to hear of what happens on this ${thing}.`;
258 if (subscription.ignored) return `You ignore this ${thing}: you hear of nothing on it, not even a mention.`;
259 if (!subscription.subscribed) return "You're not subscribed. You'll still hear if you're mentioned or asked to review.";
260 switch (subscription.reason) {
261 case "author":
262 return `You're subscribed because you opened this ${thing}, or asked g1t for it.`;
263 case "assign":
264 return "You're subscribed because you were assigned.";
265 case "review_requested":
266 return "You're subscribed because you were asked to review.";
267 case "comment":
268 return "You're subscribed because you commented.";
269 case "mention":
270 return "You're subscribed because you were mentioned.";
271 case "team_mention":
272 return "You're subscribed because a team you're in was mentioned.";
273 default:
274 return `You're subscribed to this ${thing}.`;
275 }
276}
277
278/** What a posted subscription form asks for: subscribe, unsubscribe, ignore, or the default. */
279export function subscriptionFromForm(form: FormData): { subscribed: boolean | null; ignored: boolean } | null {
280 switch (String(form.get("intent") ?? "")) {
281 case "subscribe":
282 return { subscribed: true, ignored: false };
283 case "unsubscribe":
284 return { subscribed: false, ignored: false };
285 case "ignore":
286 return { subscribed: false, ignored: true };
287 case "default":
288 return { subscribed: null, ignored: false };
289 default:
290 return null;
291 }
292}
293
294/** The reasons someone can be emailed for, in the settings' order, with what each is. */
295export const EMAIL_REASONS: { reason: InboxReason; label: string; detail: string }[] = [
296 { reason: "agent", label: "An agent is waiting on you", detail: "It asked you something, or stopped until you step in." },
297 { reason: "review_requested", label: "You're asked to review", detail: "Someone asked for your review of a pull request." },
298 { reason: "mention", label: "You're mentioned", detail: "Someone wrote your @username in a comment." },
299 { reason: "team_mention", label: "Your team is mentioned", detail: "Someone wrote @workspace/team for a team you're in." },
300 { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." },
301 { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." },
302 { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." },
303 { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." },
304 { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." },
305 { reason: "manual", label: "Threads you subscribed to", detail: "Activity on issues and pull requests you subscribed to by hand." },
306 { reason: "subscribed", label: "Repositories you watch", detail: "Activity in repositories you watch." },
307];
308
309/** The reasons checked on the settings form, each once, in rank order. */
310export function emailReasonsFromForm(form: FormData): InboxReason[] {
311 const checked = form.getAll("email").map(String);
312 return INBOX_REASONS.filter((reason) => checked.includes(reason));
313}