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