Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings
Cards say why each thread reached you and how many updates it has, timed by its latest activity; /inbox filters by reason. Issue and pull request sidebars have a Notifications box to subscribe, unsubscribe or stop ignoring, saying why you hear of it. The repository header has a Watch menu: participating and @mentions, all activity, ignore, or custom kinds. Settings has a Notifications page: what you are emailed for, how you watch repositories you create, and the repositories you watch.
12 files+762−340/12 viewed
| 13 | 13 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 14 | 14 | import { cn } from "../lib/cn"; | |
| 15 | 15 | import { isPending } from "../lib/pending"; | |
| 16 | − | import { INBOX_TABS, type InboxTab, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, whenShort } from "../lib/inbox"; | |
| 16 | + | import { INBOX_TABS, type InboxTab, REASON_LABEL, SEVERITY_LABEL, SNOOZES, bellCount, emptyFor, isUnread, tabCount, updatesLabel, whenShort } from "../lib/inbox"; | |
| 17 | 17 | import type { InboxPanelData } from "../routes/inbox-json"; | |
| 18 | 18 | ||
| 19 | 19 | /** Where every inbox form posts (routes/inbox.tsx). */ | |
| 69 | 69 | const unread = isUnread(item); | |
| 70 | 70 | const leaving = isPending(fetcher, { id: item.id }) && ["done", "snooze", "undone"].includes(String(fetcher.formData?.get("intent"))); | |
| 71 | 71 | const [now, setNow] = useState(() => Date.now()); | |
| 72 | − | useEffect(() => setNow(Date.now()), [item.createdAt]); | |
| 72 | + | useEffect(() => setNow(Date.now()), [item.updatedAt]); | |
| 73 | 73 | if (leaving) return null; | |
| 74 | 74 | ||
| 75 | 75 | const submit = (intent: string, extra?: Record<string, string>) => | |
| 96 | 96 | {item.title} | |
| 97 | 97 | </Link> | |
| 98 | 98 | {item.body && <p className="mt-0.5 truncate text-xs text-muted">{item.body}</p>} | |
| 99 | − | <div className="mt-2 flex items-center gap-2 text-xs text-faint"> | |
| 100 | − | <time dateTime={item.createdAt} suppressHydrationWarning> | |
| 101 | − | {whenShort(item.createdAt, now)} | |
| 99 | + | <div className="mt-2 flex min-w-0 items-center gap-2 text-xs text-faint"> | |
| 100 | + | <time dateTime={item.updatedAt} suppressHydrationWarning className="shrink-0 whitespace-nowrap"> | |
| 101 | + | {whenShort(item.updatedAt, now)} | |
| 102 | 102 | </time> | |
| 103 | − | <Badge tone={TONE[item.severity]}>{SEVERITY_LABEL[item.severity]}</Badge> | |
| 103 | + | <Badge tone={TONE[item.severity]} className="shrink-0">{SEVERITY_LABEL[item.severity]}</Badge> | |
| 104 | + | {/* Why they were told, and how much has happened, said quietly. */} | |
| 105 | + | <span className="truncate">{REASON_LABEL[item.reason] ?? item.reason}</span> | |
| 106 | + | {updatesLabel(item.count) && ( | |
| 107 | + | <> | |
| 108 | + | <span aria-hidden="true">·</span> | |
| 109 | + | <span className="shrink-0 tabular-nums">{updatesLabel(item.count)}</span> | |
| 110 | + | </> | |
| 111 | + | )} | |
| 104 | 112 | {item.saved && ( | |
| 105 | 113 | <span className="inline-flex items-center gap-1 text-faint"> | |
| 106 | 114 | <Bookmark size={12} aria-hidden="true" /> |
| 1 | + | /** | |
| 2 | + | * Choosing what you hear of: subscribing to one issue or pull request, from | |
| 3 | + | * its sidebar, and watching a repository, from its header. Both post to | |
| 4 | + | * the repository's `notifications` route (routes/repo/notifications.ts); | |
| 5 | + | * the events service keeps the choice and the inbox follows it. | |
| 6 | + | */ | |
| 7 | + | import { Bell, BellOff, Check, ChevronDown, Eye, EyeOff } from "lucide-react"; | |
| 8 | + | import { useState } from "react"; | |
| 9 | + | import { useFetcher } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { WATCH_EVENTS, type ThreadSubscription, type WatchEvent, type WatchLevel } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import { SubmitButton } from "./ui"; | |
| 14 | + | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 15 | + | import { cn } from "../lib/cn"; | |
| 16 | + | import { WATCH_CHOICES, WATCH_EVENT_LABEL, subscriptionLine, watchLabel } from "../lib/inbox"; | |
| 17 | + | ||
| 18 | + | /** | |
| 19 | + | * The sidebar's Notifications box on an issue or pull request: one button | |
| 20 | + | * to subscribe or unsubscribe (or stop ignoring it), and a line saying | |
| 21 | + | * whether you hear of it, and why. While the choice is on its way, it | |
| 22 | + | * shows as made. | |
| 23 | + | */ | |
| 24 | + | export function SubscriptionBox({ | |
| 25 | + | action, | |
| 26 | + | number, | |
| 27 | + | kind, | |
| 28 | + | subscription, | |
| 29 | + | }: { | |
| 30 | + | /** The repository's notifications route: `/<owner>/<repo>/notifications`. */ | |
| 31 | + | action: string; | |
| 32 | + | number: number; | |
| 33 | + | kind: "issue" | "pull"; | |
| 34 | + | subscription: ThreadSubscription | null; | |
| 35 | + | }) { | |
| 36 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 37 | + | const asked = fetcher.formData?.get("intent"); | |
| 38 | + | // What it will be once the choice lands. | |
| 39 | + | const shown: ThreadSubscription | null = | |
| 40 | + | asked === "subscribe" | |
| 41 | + | ? { ...(subscription ?? EMPTY), subscribed: true, ignored: false, reason: subscription?.subscribed ? subscription.reason : "manual" } | |
| 42 | + | : asked === "unsubscribe" || asked === "default" | |
| 43 | + | ? { ...(subscription ?? EMPTY), subscribed: false, ignored: false, reason: null } | |
| 44 | + | : subscription; | |
| 45 | + | const intent = shown?.ignored ? "default" : shown?.subscribed ? "unsubscribe" : "subscribe"; | |
| 46 | + | const label = intent === "default" ? "Stop ignoring" : intent === "unsubscribe" ? "Unsubscribe" : "Subscribe"; | |
| 47 | + | return ( | |
| 48 | + | <section> | |
| 49 | + | <h3 className="text-sm font-medium">Notifications</h3> | |
| 50 | + | <fetcher.Form method="post" action={action} className="mt-2"> | |
| 51 | + | <input type="hidden" name="intent" value={intent} /> | |
| 52 | + | <input type="hidden" name="number" value={number} /> | |
| 53 | + | <SubmitButton | |
| 54 | + | variant="quiet" | |
| 55 | + | fetcher={fetcher} | |
| 56 | + | className="inline-flex w-full items-center justify-center gap-2 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg disabled:opacity-60" | |
| 57 | + | > | |
| 58 | + | {intent === "subscribe" ? <Bell size={14} /> : <BellOff size={14} />} | |
| 59 | + | {label} | |
| 60 | + | </SubmitButton> | |
| 61 | + | </fetcher.Form> | |
| 62 | + | <p className="mt-2 text-xs text-muted">{subscriptionLine(shown, kind)}</p> | |
| 63 | + | {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>} | |
| 64 | + | </section> | |
| 65 | + | ); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | const EMPTY: ThreadSubscription = { subscribed: false, ignored: false, reason: null, repo: null, number: null, updatedAt: null }; | |
| 69 | + | ||
| 70 | + | /** | |
| 71 | + | * The repository header's Watch menu: participating and @mentions (the | |
| 72 | + | * default), all activity, ignore, or custom, with the kinds a custom watch | |
| 73 | + | * follows checked under it. Each choice is saved as it is made. | |
| 74 | + | */ | |
| 75 | + | export function WatchMenu({ action, level, events }: { action: string; level: WatchLevel; events: WatchEvent[] }) { | |
| 76 | + | const fetcher = useFetcher<{ error?: string }>(); | |
| 77 | + | // The choice on its way, as it will be. | |
| 78 | + | const pendingLevel = fetcher.formData?.get("level"); | |
| 79 | + | const shownLevel = (typeof pendingLevel === "string" ? pendingLevel : level) as WatchLevel; | |
| 80 | + | const shownEvents = fetcher.formData ? (fetcher.formData.getAll("event").map(String) as WatchEvent[]) : events; | |
| 81 | + | // Custom opened to choose, before anything is chosen. | |
| 82 | + | const [choosing, setChoosing] = useState(false); | |
| 83 | + | const custom = shownLevel === "custom" || choosing; | |
| 84 | + | ||
| 85 | + | const save = (next: WatchLevel, kinds: WatchEvent[] = []) => { | |
| 86 | + | const form = new FormData(); | |
| 87 | + | form.set("intent", "watch"); | |
| 88 | + | form.set("level", next); | |
| 89 | + | for (const kind of kinds) form.append("event", kind); | |
| 90 | + | fetcher.submit(form, { method: "post", action }); | |
| 91 | + | }; | |
| 92 | + | const toggle = (kind: WatchEvent) => { | |
| 93 | + | const had = shownLevel === "custom" ? shownEvents : []; | |
| 94 | + | const next = had.includes(kind) ? had.filter((event) => event !== kind) : [...had, kind]; | |
| 95 | + | const ordered = WATCH_EVENTS.filter((event) => next.includes(event)); | |
| 96 | + | // Nothing left to follow is the default. | |
| 97 | + | save(ordered.length > 0 ? "custom" : "participating", ordered); | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | return ( | |
| 101 | + | <DropdownMenu onOpenChange={(open) => !open && setChoosing(false)}> | |
| 102 | + | <DropdownMenuTrigger | |
| 103 | + | aria-label={`Watch: ${WATCH_CHOICES.find((choice) => choice.level === shownLevel)?.label ?? "Participating and @mentions"}`} | |
| 104 | + | className={cn( | |
| 105 | + | "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-fg/80 transition-colors outline-none hover:border-line-strong hover:bg-surface hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:border-line-strong", | |
| 106 | + | fetcher.state !== "idle" && "opacity-70", | |
| 107 | + | )} | |
| 108 | + | > | |
| 109 | + | {shownLevel === "ignore" ? <EyeOff size={14} /> : <Eye size={14} />} | |
| 110 | + | {/* The eye says it on a phone; the aria-label says it in full. */} | |
| 111 | + | <span className="hidden sm:inline">{watchLabel(shownLevel)}</span> | |
| 112 | + | <ChevronDown size={13} className="text-faint" /> | |
| 113 | + | </DropdownMenuTrigger> | |
| 114 | + | <DropdownMenuContent align="end" className="w-72"> | |
| 115 | + | <DropdownMenuLabel>Notifications from this repository</DropdownMenuLabel> | |
| 116 | + | {WATCH_CHOICES.map((choice) => { | |
| 117 | + | const selected = choice.level === "custom" ? custom : !choosing && shownLevel === choice.level; | |
| 118 | + | return ( | |
| 119 | + | <DropdownMenuItem | |
| 120 | + | key={choice.level} | |
| 121 | + | onSelect={(event) => { | |
| 122 | + | if (choice.level === "custom") { | |
| 123 | + | // Stays open, to choose what to follow. | |
| 124 | + | event.preventDefault(); | |
| 125 | + | setChoosing(true); | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | setChoosing(false); | |
| 129 | + | if (choice.level !== shownLevel) save(choice.level); | |
| 130 | + | }} | |
| 131 | + | className="items-start" | |
| 132 | + | > | |
| 133 | + | <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> | |
| 134 | + | {selected && <Check size={14} className="!text-accent" />} | |
| 135 | + | </span> | |
| 136 | + | <span className="min-w-0"> | |
| 137 | + | <span className="block text-sm text-fg">{choice.label}</span> | |
| 138 | + | <span className="block text-xs text-muted">{choice.detail}</span> | |
| 139 | + | </span> | |
| 140 | + | </DropdownMenuItem> | |
| 141 | + | ); | |
| 142 | + | })} | |
| 143 | + | {custom && ( | |
| 144 | + | <> | |
| 145 | + | <DropdownMenuSeparator /> | |
| 146 | + | <DropdownMenuLabel>Also tell me about</DropdownMenuLabel> | |
| 147 | + | {WATCH_EVENTS.map((kind) => { | |
| 148 | + | const on = shownLevel === "custom" && shownEvents.includes(kind); | |
| 149 | + | return ( | |
| 150 | + | <DropdownMenuItem | |
| 151 | + | key={kind} | |
| 152 | + | role="menuitemcheckbox" | |
| 153 | + | aria-checked={on} | |
| 154 | + | onSelect={(event) => { | |
| 155 | + | event.preventDefault(); | |
| 156 | + | toggle(kind); | |
| 157 | + | }} | |
| 158 | + | > | |
| 159 | + | <span | |
| 160 | + | aria-hidden="true" | |
| 161 | + | className={cn( | |
| 162 | + | "flex size-4 shrink-0 items-center justify-center rounded border", | |
| 163 | + | on ? "border-accent bg-accent text-bg" : "border-line-strong", | |
| 164 | + | )} | |
| 165 | + | > | |
| 166 | + | {on && <Check size={11} className="!text-bg" />} | |
| 167 | + | </span> | |
| 168 | + | {WATCH_EVENT_LABEL[kind]} | |
| 169 | + | </DropdownMenuItem> | |
| 170 | + | ); | |
| 171 | + | })} | |
| 172 | + | </> | |
| 173 | + | )} | |
| 174 | + | {fetcher.data?.error && <p className="px-2 py-1.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 175 | + | </DropdownMenuContent> | |
| 176 | + | </DropdownMenu> | |
| 177 | + | ); | |
| 178 | + | } |
| 6 | 6 | export type AccountSettingsPage = | |
| 7 | 7 | | "profile" | |
| 8 | 8 | | "emails" | |
| 9 | + | | "notifications" | |
| 9 | 10 | | "invites" | |
| 10 | 11 | | "keys" | |
| 11 | 12 | | "tokens" | |
| 21 | 22 | about: | |
| 22 | 23 | "Your primary address gets account mail and password resets. Any confirmed address signs you in and can reset your password, and commits that carry it are shown as yours.", | |
| 23 | 24 | }, | |
| 25 | + | notifications: { | |
| 26 | + | title: "Notifications", | |
| 27 | + | about: "What you are also emailed for, and how you watch repositories. Everything comes to your inbox either way.", | |
| 28 | + | }, | |
| 24 | 29 | invites: { title: "Invites", about: "Bring people to g1t, and see which invites were used." }, | |
| 25 | 30 | keys: { title: "SSH keys", about: "Keys that let git on your computers reach g1t as you." }, | |
| 26 | 31 | tokens: { |
| 3 | 3 | ||
| 4 | 4 | import type { InboxItem } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { bellCount, emptyFor, inboxTab, inboxView, markFromForm, needsYou, severityOf, snoozeUntil, tabCount, whenShort } from "./inbox.ts"; | |
| 6 | + | import { | |
| 7 | + | EMAIL_REASONS, | |
| 8 | + | REASON_FILTERS, | |
| 9 | + | REASON_LABEL, | |
| 10 | + | bellCount, | |
| 11 | + | emailReasonsFromForm, | |
| 12 | + | emptyFor, | |
| 13 | + | inboxReason, | |
| 14 | + | inboxTab, | |
| 15 | + | inboxView, | |
| 16 | + | markFromForm, | |
| 17 | + | needsYou, | |
| 18 | + | severityOf, | |
| 19 | + | snoozeUntil, | |
| 20 | + | subscriptionFromForm, | |
| 21 | + | subscriptionLine, | |
| 22 | + | tabCount, | |
| 23 | + | updatesLabel, | |
| 24 | + | watchFromForm, | |
| 25 | + | watchLabel, | |
| 26 | + | whenShort, | |
| 27 | + | } from "./inbox.ts"; | |
| 7 | 28 | ||
| 8 | 29 | const NOW = Date.parse("2026-10-07T12:00:00.000Z"); | |
| 9 | 30 | ||
| 69 | 90 | test("what needs you puts a waiting agent before a failure, and leaves out the rest", () => { | |
| 70 | 91 | const item = (id: string, severity: InboxItem["severity"], createdAt: string, readAt: string | null = null): InboxItem => ({ | |
| 71 | 92 | id, | |
| 72 | − | reason: "x", | |
| 93 | + | reason: "agent", | |
| 73 | 94 | severity, | |
| 74 | 95 | title: id, | |
| 75 | 96 | body: "", | |
| 80 | 101 | url: "/acme/rocket/pull/1", | |
| 81 | 102 | actor: null, | |
| 82 | 103 | createdAt, | |
| 104 | + | updatedAt: createdAt, | |
| 105 | + | event: null, | |
| 106 | + | count: 1, | |
| 83 | 107 | readAt, | |
| 84 | 108 | doneAt: null, | |
| 85 | 109 | saved: false, | |
| 107 | 131 | assert.equal(emptyFor("needs").title, "Nothing needs you"); | |
| 108 | 132 | assert.equal(emptyFor("all", "saved").title, "Nothing saved"); | |
| 109 | 133 | }); | |
| 134 | + | ||
| 135 | + | test("every reason has words, a filter and an email choice", () => { | |
| 136 | + | const reasons = Object.keys(REASON_LABEL); | |
| 137 | + | assert.equal(reasons.length, 11); | |
| 138 | + | assert.deepEqual(REASON_FILTERS.slice(1).map((entry) => entry.reason), reasons); | |
| 139 | + | assert.equal(REASON_FILTERS[0].label, "Any reason"); | |
| 140 | + | assert.equal(REASON_FILTERS.find((entry) => entry.reason === "review_requested")?.label, "Review requested"); | |
| 141 | + | // Every reason someone can be told for can be emailed, but a security | |
| 142 | + | // alert, which nothing sends yet. | |
| 143 | + | assert.deepEqual( | |
| 144 | + | EMAIL_REASONS.map((entry) => entry.reason).sort(), | |
| 145 | + | reasons.filter((reason) => reason !== "security_alert").sort(), | |
| 146 | + | ); | |
| 147 | + | assert.equal(inboxReason("mention"), "mention"); | |
| 148 | + | assert.equal(inboxReason("gossip"), null); | |
| 149 | + | assert.equal(inboxReason(null), null); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | test("a thread says how much happened on it once more than one thing has", () => { | |
| 153 | + | assert.equal(updatesLabel(1), ""); | |
| 154 | + | assert.equal(updatesLabel(null), ""); | |
| 155 | + | assert.equal(updatesLabel(4), "4 updates"); | |
| 156 | + | }); | |
| 157 | + | ||
| 158 | + | test("watching is read from the menu, and a custom watch of nothing is the default", () => { | |
| 159 | + | const form = (fields: [string, string][]) => { | |
| 160 | + | const data = new FormData(); | |
| 161 | + | for (const [name, value] of fields) data.append(name, value); | |
| 162 | + | return data; | |
| 163 | + | }; | |
| 164 | + | assert.deepEqual(watchFromForm(form([["level", "all"]])), { level: "all", events: [] }); | |
| 165 | + | assert.deepEqual(watchFromForm(form([["level", "custom"], ["event", "deployments"], ["event", "pulls"], ["event", "releases"]])), { | |
| 166 | + | level: "custom", | |
| 167 | + | events: ["pulls", "deployments"], | |
| 168 | + | }); | |
| 169 | + | assert.deepEqual(watchFromForm(form([["level", "custom"]])), { level: "participating", events: [] }); | |
| 170 | + | assert.equal(watchFromForm(form([["level", "loud"]])), null); | |
| 171 | + | assert.equal(watchLabel("all"), "Watching"); | |
| 172 | + | assert.equal(watchLabel("ignore"), "Ignoring"); | |
| 173 | + | assert.equal(watchLabel("participating"), "Watch"); | |
| 174 | + | }); | |
| 175 | + | ||
| 176 | + | test("the subscribe button says whether, and why", () => { | |
| 177 | + | const sub = (changes: object) => ({ subscribed: true, ignored: false, reason: null, repo: null, number: 7, updatedAt: null, ...changes }); | |
| 178 | + | assert.equal(subscriptionLine(sub({ reason: "assign" }), "issue"), "You're subscribed because you were assigned."); | |
| 179 | + | assert.equal(subscriptionLine(sub({ reason: "author" }), "pull"), "You're subscribed because you opened this pull request, or asked g1t for it."); | |
| 180 | + | assert.match(subscriptionLine(sub({ subscribed: false }), "issue"), /still hear if you're mentioned/); | |
| 181 | + | assert.match(subscriptionLine(sub({ subscribed: false, ignored: true }), "issue"), /ignore this issue/); | |
| 182 | + | assert.match(subscriptionLine(null, "issue"), /^Subscribe/); | |
| 183 | + | const intent = (value: string) => { | |
| 184 | + | const data = new FormData(); | |
| 185 | + | data.set("intent", value); | |
| 186 | + | return subscriptionFromForm(data); | |
| 187 | + | }; | |
| 188 | + | assert.deepEqual(intent("subscribe"), { subscribed: true, ignored: false }); | |
| 189 | + | assert.deepEqual(intent("unsubscribe"), { subscribed: false, ignored: false }); | |
| 190 | + | assert.deepEqual(intent("ignore"), { subscribed: false, ignored: true }); | |
| 191 | + | assert.deepEqual(intent("default"), { subscribed: null, ignored: false }); | |
| 192 | + | assert.equal(intent("explode"), null); | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | test("email reasons are read from the settings form in rank order", () => { | |
| 196 | + | const data = new FormData(); | |
| 197 | + | for (const value of ["mention", "nope", "agent", "mention"]) data.append("email", value); | |
| 198 | + | assert.deepEqual(emailReasonsFromForm(data), ["agent", "mention"]); | |
| 199 | + | assert.deepEqual(emailReasonsFromForm(new FormData()), []); | |
| 200 | + | }); |
| 1 | 1 | /** | |
| 2 | − | * The inbox's tabs, times and actions, shared by the panel in the top bar | |
| 3 | − | * (components/inbox.tsx) and the page at /inbox (routes/inbox.tsx). The | |
| 4 | − | * events service keeps the items and decides who is told of what. | |
| 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. | |
| 5 | 7 | */ | |
| 6 | − | import type { InboxCounts, InboxItem, InboxMarkArgs, InboxSeverity, InboxView } from "@g1t/contracts"; | |
| 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"]; | |
| 7 | 36 | ||
| 8 | 37 | export type InboxTab = "all" | "needs" | "error" | "success" | "info"; | |
| 9 | 38 | ||
| 10 | 39 | /** The tabs, in order, and the severity each shows. */ | |
| 11 | 40 | export const INBOX_TABS: { tab: InboxTab; label: string; severity: InboxSeverity | null }[] = [ | |
| 12 | 41 | { tab: "all", label: "All", severity: null }, | |
| 13 | − | // Warnings are what an agent is waiting on a person for. | |
| 42 | + | // Warnings are what is waiting on a person: an agent, or a review asked of them. | |
| 14 | 43 | { tab: "needs", label: "Needs you", severity: "warning" }, | |
| 15 | 44 | { tab: "error", label: "Errors", severity: "error" }, | |
| 16 | 45 | { tab: "success", label: "Success", severity: "success" }, | |
| 56 | 85 | if (view === "done") return { title: "Nothing done yet", detail: "Items you mark done move here." }; | |
| 57 | 86 | switch (tab) { | |
| 58 | 87 | case "needs": | |
| 59 | − | return { title: "Nothing needs you", detail: "When an agent is waiting on you, it shows up here first." }; | |
| 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." }; | |
| 60 | 89 | case "error": | |
| 61 | 90 | return { title: "No failures", detail: "Failed checks and workflows on your work show up here." }; | |
| 62 | 91 | case "success": | |
| 136 | 165 | const rank = (item: InboxItem) => (item.severity === "warning" ? 0 : 1); | |
| 137 | 166 | const needs = items | |
| 138 | 167 | .filter((item) => isUnread(item) && (item.severity === "warning" || item.severity === "error")) | |
| 139 | − | .sort((a, b) => rank(a) - rank(b) || b.createdAt.localeCompare(a.createdAt)); | |
| 168 | + | .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt)); | |
| 140 | 169 | return { items: needs.slice(0, max), total: needs.length }; | |
| 141 | 170 | } | |
| 142 | 171 | ||
| 144 | 173 | export function isUnread(item: Pick<InboxItem, "readAt">): boolean { | |
| 145 | 174 | return item.readAt == null; | |
| 146 | 175 | } | |
| 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 | + | } |
| 27 | 27 | index("routes/settings/index.tsx"), | |
| 28 | 28 | route("profile", "routes/settings/profile.tsx"), | |
| 29 | 29 | route("emails", "routes/settings/emails.tsx"), | |
| 30 | + | route("notifications", "routes/settings/notifications.tsx"), | |
| 30 | 31 | route("invites", "routes/settings/invites.tsx"), | |
| 31 | 32 | route("keys", "routes/settings/keys.tsx"), | |
| 32 | 33 | route("tokens", "routes/settings/tokens.tsx"), | |
| 93 | 94 | route(":owner/:repo/audit.json", "routes/repo/audit-live.ts"), | |
| 94 | 95 | // An invitation to a repository, answered by someone who cannot see it yet. | |
| 95 | 96 | route(":owner/:repo/invitations", "routes/repo/invitations.tsx"), | |
| 97 | + | // Subscribing to its issues and pull requests, and watching it. | |
| 98 | + | route(":owner/:repo/notifications", "routes/repo/notifications.ts"), | |
| 96 | 99 | // A starter CI workflow, opened as a pull request (components/add-ci.tsx). | |
| 97 | 100 | route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"), | |
| 98 | 101 | // A screenshot of a project's production, for its overview. |
| 8 | 8 | ||
| 9 | 9 | import type { Route } from "./+types/inbox"; | |
| 10 | 10 | import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox"; | |
| 11 | + | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 11 | 12 | import { cn } from "../lib/cn"; | |
| 12 | − | import { inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 13 | + | import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| 13 | 14 | import { page } from "../lib/meta"; | |
| 14 | 15 | import { inbox } from "../lib/services.server"; | |
| 15 | 16 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 25 | 26 | const url = new URL(request.url); | |
| 26 | 27 | const tab = inboxTab(url.searchParams.get("tab")); | |
| 27 | 28 | const view = inboxView(url.searchParams.get("view")); | |
| 29 | + | const reason = inboxReason(url.searchParams.get("reason")); | |
| 28 | 30 | const before = url.searchParams.get("before"); | |
| 29 | 31 | const [list, counts] = await Promise.all([ | |
| 30 | − | inbox.list(user, { view, severity: severityOf(tab), before, limit: PAGE_ITEMS }).catch(() => null), | |
| 32 | + | inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null), | |
| 31 | 33 | inbox.counts(user.username).catch(() => null), | |
| 32 | 34 | ]); | |
| 33 | − | return { tab, view, before, items: list?.items ?? null, next: list?.next ?? null, counts }; | |
| 35 | + | return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts }; | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | export async function action({ request, context }: Route.ActionArgs) { | |
| 53 | 55 | ] as const; | |
| 54 | 56 | ||
| 55 | 57 | export default function InboxPage({ loaderData }: Route.ComponentProps) { | |
| 56 | − | const { tab, view, before, items, next, counts } = loaderData; | |
| 58 | + | const { tab, view, reason, before, items, next, counts } = loaderData; | |
| 57 | 59 | const navigate = useNavigate(); | |
| 58 | 60 | const address = (changes: Record<string, string | null>) => { | |
| 59 | 61 | const params = new URLSearchParams(); | |
| 60 | − | const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, ...changes }; | |
| 62 | + | const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes }; | |
| 61 | 63 | for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value); | |
| 62 | 64 | const query = params.toString(); | |
| 63 | 65 | return query ? `/inbox?${query}` : "/inbox"; | |
| 68 | 70 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 69 | 71 | <div> | |
| 70 | 72 | <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1> | |
| 71 | − | <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What an agent is waiting on comes first.</p> | |
| 73 | + | <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What is waiting on you comes first.</p> | |
| 72 | 74 | </div> | |
| 73 | 75 | {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />} | |
| 74 | 76 | </div> | |
| 90 | 92 | ))} | |
| 91 | 93 | </nav> | |
| 92 | 94 | ||
| 93 | − | <div className="mt-4"> | |
| 94 | − | <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} /> | |
| 95 | + | <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 96 | + | <div className="min-w-0 grow"> | |
| 97 | + | <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} /> | |
| 98 | + | </div> | |
| 99 | + | {/* Why you were told: a review asked of you, a mention, what you watch. */} | |
| 100 | + | <Select value={reason ?? "any"} onValueChange={(value) => navigate(address({ reason: value === "any" ? null : value, before: null }))}> | |
| 101 | + | <SelectTrigger size="sm" aria-label="Reason" className="sm:w-44"> | |
| 102 | + | <SelectValue /> | |
| 103 | + | </SelectTrigger> | |
| 104 | + | <SelectContent align="end"> | |
| 105 | + | {REASON_FILTERS.map((entry) => ( | |
| 106 | + | <SelectItem key={entry.reason ?? "any"} value={entry.reason ?? "any"}> | |
| 107 | + | {entry.label} | |
| 108 | + | </SelectItem> | |
| 109 | + | ))} | |
| 110 | + | </SelectContent> | |
| 111 | + | </Select> | |
| 95 | 112 | </div> | |
| 96 | 113 | ||
| 97 | 114 | <div className="mt-4"> |
| 37 | 37 | import { notFound } from "../../lib/not-found.server"; | |
| 38 | 38 | import { openedBy } from "../../lib/opened-by"; | |
| 39 | 39 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 40 | − | import { identity, integrations, work } from "../../lib/services.server"; | |
| 40 | + | import { identity, inbox, integrations, work } from "../../lib/services.server"; | |
| 41 | 41 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 42 | − | import { accessTo, refusal } from "../../lib/access.server"; | |
| 42 | + | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 43 | + | import { SubscriptionBox } from "../../components/notifications"; | |
| 43 | 44 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 44 | 45 | ||
| 45 | 46 | ||
| 66 | 67 | // At once: only the plan's note waits for the viewer's role. Putting an | |
| 67 | 68 | // agent on it needs Write: Read cannot spend compute. | |
| 68 | 69 | const access = accessTo(context, params); | |
| 69 | − | const [{ can }, found, labels, agentsEnabled, members, links, computeNote] = await Promise.all([ | |
| 70 | + | const [{ can }, found, labels, agentsEnabled, members, links, computeNote, subscription] = await Promise.all([ | |
| 70 | 71 | access, | |
| 71 | 72 | work.getIssue(path, number, viewer), | |
| 72 | 73 | work.listLabels(path, viewer), | |
| 77 | 78 | integrations.links(path, number).catch(() => []), | |
| 78 | 79 | // Before a member assigns g1t: whether the workspace's plan lets it start. | |
| 79 | 80 | access.then(({ can }) => (can.run ? computeNoteFor(params.owner, "agent") : null)), | |
| 81 | + | // Whether the viewer hears of it, for the sidebar's Notifications. | |
| 82 | + | viewer | |
| 83 | + | ? repoFor(context, params).then((repo) => | |
| 84 | + | repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null, | |
| 85 | + | ) | |
| 86 | + | : null, | |
| 80 | 87 | ]); | |
| 81 | 88 | if (!found.ok) { | |
| 82 | 89 | // Issues and pull requests share numbers; this one may be a pull request. | |
| 92 | 99 | agentsEnabled, | |
| 93 | 100 | computeNote, | |
| 94 | 101 | links, | |
| 102 | + | subscription, | |
| 95 | 103 | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 96 | 104 | // The author can close and reopen their own issue, and whoever g1t's | |
| 97 | 105 | // agent filed one for, that one; Triage and up, anyone's. | |
| 638 | 646 | </details> | |
| 639 | 647 | )} | |
| 640 | 648 | ||
| 649 | + | {viewer && ( | |
| 650 | + | <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} /> | |
| 651 | + | )} | |
| 641 | 652 | </aside> | |
| 642 | 653 | </div> | |
| 643 | 654 | ); |
| 1 | 1 | import { Box, Lock } from "lucide-react"; | |
| 2 | + | import type { ReactNode } from "react"; | |
| 2 | 3 | import { Link, NavLink, Outlet, type ShouldRevalidateFunctionArgs, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import type { Project } from "@g1t/contracts"; | |
| 8 | 9 | import { page } from "../../lib/meta"; | |
| 9 | 10 | import { type Tab as PageTab, tabsFor } from "../../lib/project-nav"; | |
| 10 | 11 | import { Pill } from "../../components/ui"; | |
| 12 | + | import { WatchMenu } from "../../components/notifications"; | |
| 11 | 13 | import { ArchivedBanner } from "../../components/repo-lifecycle"; | |
| 12 | 14 | import { WelcomeBanner } from "../../components/welcome"; | |
| 13 | 15 | import { clearWelcome, welcomes } from "../../lib/invites"; | |
| 14 | 16 | import { notFound } from "../../lib/not-found.server"; | |
| 15 | 17 | import { redirectIfRenamed, redirectIfTransferred } from "../../lib/renamed.server"; | |
| 16 | 18 | import { accessFor, countsFor, repoFor } from "../../lib/access.server"; | |
| 17 | − | import { projects } from "../../lib/services.server"; | |
| 19 | + | import { inbox, projects } from "../../lib/services.server"; | |
| 18 | 20 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 19 | 21 | ||
| 20 | 22 | export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) { | |
| 24 | 26 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 25 | 27 | const viewer = getViewer(context); | |
| 26 | 28 | const path = { namespace: params.owner, name: params.repo }; | |
| 27 | − | const [repo, counts, found] = await Promise.all([ | |
| 29 | + | const [repo, counts, found, watching] = await Promise.all([ | |
| 28 | 30 | repoFor(context, params), | |
| 29 | 31 | countsFor(context, params), | |
| 30 | 32 | projects.get(params.owner, params.repo, viewer), | |
| 33 | + | // How the person watches it, for the header's Watch menu, as soon as | |
| 34 | + | // the repository is known. | |
| 35 | + | viewer | |
| 36 | + | ? repoFor(context, params).then((found) => (found.ok ? inbox.watching(viewer.username, found.value.id).catch(() => null) : null)) | |
| 37 | + | : null, | |
| 31 | 38 | ]); | |
| 32 | 39 | if (!repo.ok && !found.ok) { | |
| 33 | 40 | // Under a workspace's old name, after a rename: the project is at the new one. | |
| 56 | 63 | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 57 | 64 | access, | |
| 58 | 65 | member: access.insider, | |
| 66 | + | watching, | |
| 59 | 67 | }, { headers }); | |
| 60 | 68 | } | |
| 61 | 69 | ||
| 75 | 83 | } | |
| 76 | 84 | ||
| 77 | 85 | /** A repository's topics, each a way into Explore. */ | |
| 78 | − | function Header({ project, isPrivate, archived, namespace, name, description, large }: { | |
| 86 | + | function Header({ project, isPrivate, archived, namespace, name, description, large, actions }: { | |
| 79 | 87 | project: Project | null; | |
| 80 | 88 | isPrivate: boolean; | |
| 81 | 89 | namespace: string; | |
| 83 | 91 | description: string | null; | |
| 84 | 92 | archived?: boolean; | |
| 85 | 93 | large?: boolean; | |
| 94 | + | /** At the end of the row: the Watch menu. */ | |
| 95 | + | actions?: ReactNode; | |
| 86 | 96 | }) { | |
| 87 | 97 | const base = `/${namespace}/${name}`; | |
| 88 | 98 | return ( | |
| 103 | 113 | </h1> | |
| 104 | 114 | <Pill>{isPrivate ? "private" : "public"}</Pill> | |
| 105 | 115 | {archived && <Pill>archived</Pill>} | |
| 106 | − | {description && <p className="min-w-0 truncate text-sm text-muted">{description}</p>} | |
| 116 | + | {/* On a phone, on its own line under the name and the Watch menu. */} | |
| 117 | + | {description && <p className="order-last min-w-0 basis-full truncate text-sm text-muted sm:order-none sm:basis-0 sm:flex-1">{description}</p>} | |
| 118 | + | {actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>} | |
| 107 | 119 | </div> | |
| 108 | 120 | ); | |
| 109 | 121 | } | |
| 149 | 161 | } | |
| 150 | 162 | ||
| 151 | 163 | export default function ProjectLayout({ loaderData }: Route.ComponentProps) { | |
| 152 | − | const { repo, project, member, access, welcome } = loaderData; | |
| 164 | + | const { repo, project, member, access, welcome, watching } = loaderData; | |
| 153 | 165 | const base = `/${repo.namespace}/${repo.name}`; | |
| 154 | 166 | // The project's own description, else the repository's as it is now. | |
| 155 | 167 | const description = (project && !project.descriptionInherited ? project.description : null) ?? repo.description; | |
| 171 | 183 | name={repo.name} | |
| 172 | 184 | // The files' own About says it there, as the one place. | |
| 173 | 185 | description={filesPage ? null : description} | |
| 186 | + | actions={ | |
| 187 | + | watching ? ( | |
| 188 | + | <WatchMenu action={`${base}/notifications`} level={watching.level} events={watching.events} /> | |
| 189 | + | ) : null | |
| 190 | + | } | |
| 174 | 191 | /> | |
| 175 | 192 | {/* On the files' pages, About shows them. */} | |
| 176 | 193 | {!filesPage && <Topics topics={repo.topics} />} |
| 1 | + | /** | |
| 2 | + | * What a person hears of from one repository: subscribing to one of its | |
| 3 | + | * issues or pull requests (`intent` subscribe, unsubscribe, ignore or | |
| 4 | + | * default, with its `number`), and watching it (`intent` watch, with | |
| 5 | + | * `level` and, for custom, the `event` kinds). Posted by the sidebar's | |
| 6 | + | * Notifications box and the header's Watch menu (components/notifications.tsx). | |
| 7 | + | * Only someone who can read the repository may choose. | |
| 8 | + | */ | |
| 9 | + | import { data } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { Route } from "./+types/notifications"; | |
| 12 | + | import { subscriptionFromForm, watchFromForm } from "../../lib/inbox"; | |
| 13 | + | import { inbox, repos } from "../../lib/services.server"; | |
| 14 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 15 | + | ||
| 16 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 17 | + | assertSameOrigin(request); | |
| 18 | + | const user = requireUser(context, request); | |
| 19 | + | const form = await request.formData(); | |
| 20 | + | const repo = await repos.get({ namespace: params.owner, name: params.repo }, user); | |
| 21 | + | if (!repo.ok) return data({ error: "That repository could not be found." }, { status: 404 }); | |
| 22 | + | const path = `${repo.value.namespace}/${repo.value.name}`; | |
| 23 | + | try { | |
| 24 | + | if (form.get("intent") === "watch") { | |
| 25 | + | const watch = watchFromForm(form); | |
| 26 | + | if (!watch) return data({ error: "Choose how to watch it." }, { status: 400 }); | |
| 27 | + | // Participating is the default: no choice to keep. | |
| 28 | + | const level = watch.level === "participating" ? null : watch.level; | |
| 29 | + | return { error: null, watching: await inbox.watch(user.username, repo.value.id, path, level, watch.events) }; | |
| 30 | + | } | |
| 31 | + | const choice = subscriptionFromForm(form); | |
| 32 | + | const number = Number(form.get("number")); | |
| 33 | + | if (!choice || !Number.isInteger(number) || number < 1) return data({ error: "Nothing to do." }, { status: 400 }); | |
| 34 | + | const subscription = await inbox.subscribe(user, { repoId: repo.value.id, number }, choice.subscribed, choice.ignored); | |
| 35 | + | if (!subscription) return data({ error: "That issue or pull request could not be found." }, { status: 404 }); | |
| 36 | + | return { error: null, subscription }; | |
| 37 | + | } catch { | |
| 38 | + | return data({ error: "That did not save. Try again in a moment." }, { status: 503 }); | |
| 39 | + | } | |
| 40 | + | } |
| 75 | 75 | import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up"; | |
| 76 | 76 | import { notFound } from "../../lib/not-found.server"; | |
| 77 | 77 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 78 | − | import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server"; | |
| 78 | + | import { actions, deployments, identity, inbox, projects, repos, work } from "../../lib/services.server"; | |
| 79 | 79 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 80 | − | import { accessTo, refusal } from "../../lib/access.server"; | |
| 80 | + | import { accessTo, refusal, repoFor } from "../../lib/access.server"; | |
| 81 | + | import { SubscriptionBox } from "../../components/notifications"; | |
| 81 | 82 | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 82 | 83 | ||
| 83 | 84 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 123 | 124 | const deps = projects.dependencies(params.owner, params.repo, viewer); | |
| 124 | 125 | // Awaited below, unless the pull request is missing first. | |
| 125 | 126 | deps.catch(() => null); | |
| 126 | − | const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed] = await Promise.all([ | |
| 127 | + | const [{ can }, found, repo, settings, agentsEnabled, members, computeNote, session, deployed, subscription] = await Promise.all([ | |
| 127 | 128 | access, | |
| 128 | 129 | pullFound, | |
| 129 | 130 | repos.get(path, viewer), | |
| 136 | 137 | tab === "session" ? work.readSession(path, number, viewer) : null, | |
| 137 | 138 | // Its preview, for people with a role here: beside the rest, not after. | |
| 138 | 139 | access.then(({ insider }) => (insider ? deployments.list(ref, viewer).catch(() => null) : null)), | |
| 140 | + | // Whether the viewer hears of it, for the sidebar's Notifications. | |
| 141 | + | viewer | |
| 142 | + | ? repoFor(context, params).then((found) => | |
| 143 | + | found.ok ? inbox.subscription(viewer, { repoId: found.value.id, number }).catch(() => null) : null, | |
| 144 | + | ) | |
| 145 | + | : null, | |
| 139 | 146 | ]); | |
| 140 | 147 | if (!found.ok) { | |
| 141 | 148 | // Issues and pull requests share numbers; this one may be an issue. | |
| 197 | 204 | canUpdate: pull.fork ? viewer?.id === workOwner(pull).id : can.push, | |
| 198 | 205 | agentsEnabled, | |
| 199 | 206 | computeNote, | |
| 207 | + | subscription, | |
| 200 | 208 | members: members?.ok ? members.value.map((person) => person.username) : [], | |
| 201 | 209 | requireUpToDate: settings.ok && settings.value.requireUpToDate, | |
| 202 | 210 | mergeQueue: settings.ok && settings.value.mergeQueue, | |
| 1343 | 1351 | {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"} | |
| 1344 | 1352 | </p> | |
| 1345 | 1353 | </section> | |
| 1354 | + | {loaderData.viewer && ( | |
| 1355 | + | <SubscriptionBox action={`${base}/notifications`} number={pull.number} kind="pull" subscription={loaderData.subscription} /> | |
| 1356 | + | )} | |
| 1346 | 1357 | </aside> | |
| 1347 | 1358 | </div> | |
| 1348 | 1359 | </div> |
| 1 | + | /** | |
| 2 | + | * Settings → Notifications: what you are also emailed for, how you watch | |
| 3 | + | * repositories you create, and the repositories you watch other than the | |
| 4 | + | * default way. The events service keeps all three (`inbox_settings`, | |
| 5 | + | * `inbox_watched`); the inbox itself is at /inbox. | |
| 6 | + | */ | |
| 7 | + | import { Eye, EyeOff } from "lucide-react"; | |
| 8 | + | import { Form, Link, data, useNavigation } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { WatchLevel } from "@g1t/contracts"; | |
| 11 | + | ||
| 12 | + | import type { Route } from "./+types/notifications"; | |
| 13 | + | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 14 | + | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 15 | + | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 16 | + | import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox"; | |
| 17 | + | import { page } from "../../lib/meta"; | |
| 18 | + | import { inbox } from "../../lib/services.server"; | |
| 19 | + | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 20 | + | ||
| 21 | + | export function meta(args: Route.MetaArgs) { | |
| 22 | + | return page(args, { title: "Notifications · Settings · g1t" }); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 26 | + | const user = requireUser(context, request); | |
| 27 | + | const [settings, watched] = await Promise.all([ | |
| 28 | + | inbox.settings(user.username).catch(() => null), | |
| 29 | + | inbox.watched(user.username).catch(() => null), | |
| 30 | + | ]); | |
| 31 | + | return { settings, watched }; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 35 | + | assertSameOrigin(request); | |
| 36 | + | const user = requireUser(context, request); | |
| 37 | + | const form = await request.formData(); | |
| 38 | + | const intent = String(form.get("intent") ?? ""); | |
| 39 | + | // One shape for every answer: what was said, beside the form that asked. | |
| 40 | + | const answer = (saved: string | null, error: string | null = null) => ({ intent, saved, error }); | |
| 41 | + | try { | |
| 42 | + | switch (intent) { | |
| 43 | + | case "email": | |
| 44 | + | await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) }); | |
| 45 | + | return answer("Saved. You'll be emailed for what is checked."); | |
| 46 | + | case "default_watch": { | |
| 47 | + | const level = String(form.get("default_watch") ?? ""); | |
| 48 | + | if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 }); | |
| 49 | + | await inbox.updateSettings(user.username, { defaultWatch: level }); | |
| 50 | + | return answer("Saved. It applies to repositories you create from now on."); | |
| 51 | + | } | |
| 52 | + | case "unwatch": { | |
| 53 | + | const repoId = String(form.get("repo_id") ?? ""); | |
| 54 | + | if (!repoId) return data(answer(null, "Nothing to do."), { status: 400 }); | |
| 55 | + | await inbox.watch(user.username, repoId, String(form.get("repo") ?? ""), null); | |
| 56 | + | return answer(null); | |
| 57 | + | } | |
| 58 | + | default: | |
| 59 | + | return data(answer(null, "Nothing to do."), { status: 400 }); | |
| 60 | + | } | |
| 61 | + | } catch { | |
| 62 | + | return data(answer(null, "That did not save. Try again in a moment."), { status: 503 }); | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record< | |
| 67 | + | WatchLevel, | |
| 68 | + | string | |
| 69 | + | >; | |
| 70 | + | ||
| 71 | + | export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 72 | + | const { settings, watched } = loaderData; | |
| 73 | + | const navigation = useNavigation(); | |
| 74 | + | const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null); | |
| 75 | + | if (!settings) { | |
| 76 | + | return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>; | |
| 77 | + | } | |
| 78 | + | return ( | |
| 79 | + | <div className="space-y-10"> | |
| 80 | + | <section aria-labelledby="email-heading"> | |
| 81 | + | <h2 id="email-heading" className="text-sm font-semibold"> | |
| 82 | + | ||
| 83 | + | </h2> | |
| 84 | + | <p className="mt-1 text-sm text-muted"> | |
| 85 | + | Besides your <Link to="/inbox" className="text-fg underline underline-offset-4">inbox</Link>, email me at my primary address when: | |
| 86 | + | </p> | |
| 87 | + | <Form method="post" className="mt-4 space-y-3"> | |
| 88 | + | <input type="hidden" name="intent" value="email" /> | |
| 89 | + | {EMAIL_REASONS.map((entry) => ( | |
| 90 | + | <CheckboxOption | |
| 91 | + | key={entry.reason} | |
| 92 | + | name="email" | |
| 93 | + | value={entry.reason} | |
| 94 | + | defaultChecked={settings.email.includes(entry.reason)} | |
| 95 | + | label={entry.label} | |
| 96 | + | description={entry.detail} | |
| 97 | + | /> | |
| 98 | + | ))} | |
| 99 | + | <div className="flex items-center gap-3 pt-2"> | |
| 100 | + | <SubmitButton variant="quiet" match={{ intent: "email" }} pending="Saving…"> | |
| 101 | + | Save email settings | |
| 102 | + | </SubmitButton> | |
| 103 | + | {said("email")?.saved && <p className="text-xs text-accent">{said("email")?.saved}</p>} | |
| 104 | + | </div> | |
| 105 | + | <ErrorText>{said("email")?.error}</ErrorText> | |
| 106 | + | </Form> | |
| 107 | + | <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p> | |
| 108 | + | </section> | |
| 109 | + | ||
| 110 | + | <section aria-labelledby="watching-heading"> | |
| 111 | + | <h2 id="watching-heading" className="text-sm font-semibold"> | |
| 112 | + | Repositories you create | |
| 113 | + | </h2> | |
| 114 | + | <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p> | |
| 115 | + | <Form method="post" className="mt-4"> | |
| 116 | + | <input type="hidden" name="intent" value="default_watch" /> | |
| 117 | + | <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}> | |
| 118 | + | {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => ( | |
| 119 | + | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> | |
| 120 | + | ))} | |
| 121 | + | </RadioGroup> | |
| 122 | + | <div className="mt-4 flex items-center gap-3"> | |
| 123 | + | <SubmitButton variant="quiet" match={{ intent: "default_watch" }} pending="Saving…"> | |
| 124 | + | Save | |
| 125 | + | </SubmitButton> | |
| 126 | + | {said("default_watch")?.saved && <p className="text-xs text-accent">{said("default_watch")?.saved}</p>} | |
| 127 | + | </div> | |
| 128 | + | <ErrorText>{said("default_watch")?.error}</ErrorText> | |
| 129 | + | </Form> | |
| 130 | + | </section> | |
| 131 | + | ||
| 132 | + | <section aria-labelledby="watched-heading"> | |
| 133 | + | <h2 id="watched-heading" className="text-sm font-semibold"> | |
| 134 | + | Repositories you watch | |
| 135 | + | </h2> | |
| 136 | + | <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p> | |
| 137 | + | {watched == null ? ( | |
| 138 | + | <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p> | |
| 139 | + | ) : watched.length === 0 ? ( | |
| 140 | + | <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 141 | + | You watch every repository the default way. | |
| 142 | + | </p> | |
| 143 | + | ) : ( | |
| 144 | + | <ul className="mt-4 divide-y divide-line rounded-md border border-line"> | |
| 145 | + | {watched.map((watching) => ( | |
| 146 | + | <li key={watching.repoId} className="flex items-center gap-3 px-4 py-3"> | |
| 147 | + | {watching.level === "ignore" ? ( | |
| 148 | + | <EyeOff size={15} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 149 | + | ) : ( | |
| 150 | + | <Eye size={15} className="shrink-0 text-faint" aria-hidden="true" /> | |
| 151 | + | )} | |
| 152 | + | <div className="min-w-0 grow"> | |
| 153 | + | {watching.repo ? ( | |
| 154 | + | <Link to={`/${watching.repo}`} className="block truncate font-mono text-sm hover:underline"> | |
| 155 | + | {watching.repo} | |
| 156 | + | </Link> | |
| 157 | + | ) : ( | |
| 158 | + | <span className="block truncate font-mono text-sm text-muted">{watching.repoId}</span> | |
| 159 | + | )} | |
| 160 | + | <p className="truncate text-xs text-muted"> | |
| 161 | + | {LEVEL_LABEL[watching.level]} | |
| 162 | + | {watching.level === "custom" && watching.events.length > 0 && `: ${watching.events.map((kind) => WATCH_EVENT_LABEL[kind]).join(", ")}`} | |
| 163 | + | </p> | |
| 164 | + | </div> | |
| 165 | + | <Form method="post" className="shrink-0"> | |
| 166 | + | <input type="hidden" name="intent" value="unwatch" /> | |
| 167 | + | <input type="hidden" name="repo_id" value={watching.repoId} /> | |
| 168 | + | <input type="hidden" name="repo" value={watching.repo ?? ""} /> | |
| 169 | + | <SubmitButton | |
| 170 | + | variant="quiet" | |
| 171 | + | match={{ intent: "unwatch", repo_id: watching.repoId }} | |
| 172 | + | pending="…" | |
| 173 | + | className="rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-60" | |
| 174 | + | > | |
| 175 | + | {watching.level === "ignore" ? "Stop ignoring" : "Stop watching"} | |
| 176 | + | </SubmitButton> | |
| 177 | + | </Form> | |
| 178 | + | </li> | |
| 179 | + | ))} | |
| 180 | + | </ul> | |
| 181 | + | )} | |
| 182 | + | </section> | |
| 183 | + | </div> | |
| 184 | + | ); | |
| 185 | + | } |