| 1 | import { BellRing, Send } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | |
| 4 | import type { Membership, NotifyLevel, NotifyPreferences, NotifyPreferencesChange, NotifyStatus } from "@g1t/contracts"; |
| 5 | |
| 6 | import { Button } from "../ui/button"; |
| 7 | import { Card } from "../ui/card"; |
| 8 | import { RadioGroup, RadioOption } from "../ui/radio-group"; |
| 9 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; |
| 10 | import { Switch } from "../ui/switch"; |
| 11 | import { desktopBridge, disablePush, enablePush, notifyStatus, permission, pushChoice, pushSupported, savePreferences, sendTest } from "../../lib/notify-client"; |
| 12 | |
| 13 | const LEVELS: { level: NotifyLevel; label: string; detail: string }[] = [ |
| 14 | { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your notifications." }, |
| 15 | { |
| 16 | level: "dms_mentions", |
| 17 | label: "Direct messages, mentions and replies", |
| 18 | detail: "Messages to you, @mentions, replies in your threads, and agents or reviews waiting on you.", |
| 19 | }, |
| 20 | { level: "none", label: "Nothing", detail: "No pop-ups or browser notifications. Counts still update." }, |
| 21 | ]; |
| 22 | |
| 23 | type Device = "on" | "off" | "blocked" | "unsupported" | "unavailable" | "desktop"; |
| 24 | |
| 25 | function deviceState(status: NotifyStatus | null): Device { |
| 26 | if (desktopBridge()) return "desktop"; |
| 27 | if (!pushSupported()) return "unsupported"; |
| 28 | if (status && !status.vapid_public_key) return "unavailable"; |
| 29 | if (permission() === "denied") return "blocked"; |
| 30 | return permission() === "granted" && status?.subscribed && pushChoice() !== "off" ? "on" : "off"; |
| 31 | } |
| 32 | |
| 33 | const DEVICE_LINE: Record<Device, string> = { |
| 34 | on: "On. This browser shows a notification when you're away from g1t.", |
| 35 | off: "Off. You'll only see notifications while g1t is open.", |
| 36 | blocked: "Blocked by your browser. Allow notifications for this site in its settings, then come back.", |
| 37 | unsupported: "This browser can't show notifications.", |
| 38 | unavailable: "Browser notifications aren't set up on this server.", |
| 39 | desktop: "The desktop app shows notifications itself.", |
| 40 | }; |
| 41 | |
| 42 | /** |
| 43 | * Settings → Notifications, the live part: what pops up and is pushed, |
| 44 | * this browser's notifications, and a test. Kept by the notify service |
| 45 | * (services/notify). Sounds are the section above (sounds.tsx). |
| 46 | */ |
| 47 | export function LiveNotificationSettings({ workspaces, initial = null }: { workspaces: Membership[]; initial?: NotifyStatus | null }) { |
| 48 | const [status, setStatus] = useState<NotifyStatus | null>(initial); |
| 49 | const [prefs, setPrefs] = useState<NotifyPreferences | null>(initial?.preferences ?? null); |
| 50 | const [device, setDevice] = useState<Device>("off"); |
| 51 | const [busy, setBusy] = useState<string | null>(null); |
| 52 | const [said, setSaid] = useState<string | null>(null); |
| 53 | const [failed, setFailed] = useState(false); |
| 54 | |
| 55 | useEffect(() => { |
| 56 | if (initial) { |
| 57 | setDevice(deviceState(initial)); |
| 58 | return; |
| 59 | } |
| 60 | notifyStatus() |
| 61 | .then((s) => { |
| 62 | setStatus(s); |
| 63 | setPrefs(s?.preferences ?? null); |
| 64 | setDevice(deviceState(s)); |
| 65 | if (!s) setFailed(true); |
| 66 | }) |
| 67 | .catch(() => setFailed(true)); |
| 68 | }, [initial]); |
| 69 | |
| 70 | const save = async (change: NotifyPreferencesChange) => { |
| 71 | if (prefs) { |
| 72 | const workspaces = { ...prefs.workspaces }; |
| 73 | for (const [slug, level] of Object.entries(change.workspaces ?? {})) { |
| 74 | if (level) workspaces[slug] = level; |
| 75 | else delete workspaces[slug]; |
| 76 | } |
| 77 | setPrefs({ level: change.level ?? prefs.level, workspaces }); |
| 78 | } |
| 79 | setBusy("prefs"); |
| 80 | const saved = await savePreferences(change).catch(() => null); |
| 81 | setBusy(null); |
| 82 | if (saved) { |
| 83 | setPrefs(saved); |
| 84 | setSaid("Saved."); |
| 85 | } else setSaid("That didn't save. Try again in a moment."); |
| 86 | }; |
| 87 | |
| 88 | const toggleDevice = async (on: boolean) => { |
| 89 | setBusy("device"); |
| 90 | if (on) { |
| 91 | const result = await enablePush(); |
| 92 | setDevice(result === "on" ? "on" : result === "denied" ? (permission() === "denied" ? "blocked" : "off") : deviceState(status)); |
| 93 | if (result === "failed") setSaid("This browser couldn't be set up. Try again in a moment."); |
| 94 | } else { |
| 95 | await disablePush().catch(() => {}); |
| 96 | setDevice("off"); |
| 97 | } |
| 98 | setBusy(null); |
| 99 | }; |
| 100 | |
| 101 | const test = async () => { |
| 102 | setBusy("test"); |
| 103 | const sent = await sendTest().catch(() => null); |
| 104 | setBusy(null); |
| 105 | if (!sent) setSaid("The test didn't send. Try again in a moment."); |
| 106 | else if (sent.pushed > 0) setSaid(`Sent. Also pushed to ${sent.pushed === 1 ? "1 browser" : `${sent.pushed} browsers`}.`); |
| 107 | else setSaid("Sent. It shows here; turn on browser notifications to get it when you're away."); |
| 108 | }; |
| 109 | |
| 110 | if (failed) { |
| 111 | return ( |
| 112 | <section aria-labelledby="live-heading"> |
| 113 | <h2 id="live-heading" className="text-sm font-semibold"> |
| 114 | Pop-ups and browser notifications |
| 115 | </h2> |
| 116 | <Card asChild tone="plain" radius="lg" className="mt-3 px-4 py-6 text-sm text-muted"> |
| 117 | <p>These settings could not be loaded. Try again in a moment.</p> |
| 118 | </Card> |
| 119 | </section> |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | const deviceOn = device === "on"; |
| 124 | const deviceLocked = device === "unsupported" || device === "unavailable" || device === "blocked" || device === "desktop"; |
| 125 | |
| 126 | return ( |
| 127 | <section aria-labelledby="live-heading" className="space-y-6"> |
| 128 | <div> |
| 129 | <h2 id="live-heading" className="text-sm font-semibold"> |
| 130 | Pop-ups and browser notifications |
| 131 | </h2> |
| 132 | <p className="mt-1 text-sm text-muted">What pops up while g1t is open, and what reaches you when it isn't.</p> |
| 133 | </div> |
| 134 | |
| 135 | <div> |
| 136 | <h3 className="text-[0.8125rem] font-medium text-fg">Notify me about</h3> |
| 137 | <RadioGroup |
| 138 | className="mt-3" |
| 139 | value={prefs?.level ?? "dms_mentions"} |
| 140 | disabled={!prefs} |
| 141 | onValueChange={(value) => void save({ level: value as NotifyLevel })} |
| 142 | aria-label="Notify me about" |
| 143 | > |
| 144 | {LEVELS.map((choice) => ( |
| 145 | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> |
| 146 | ))} |
| 147 | </RadioGroup> |
| 148 | </div> |
| 149 | |
| 150 | {workspaces.length > 1 && prefs && ( |
| 151 | <div> |
| 152 | <h3 className="text-[0.8125rem] font-medium text-fg">For a workspace</h3> |
| 153 | <Card asChild tone="plain" radius="lg" divided className="mt-3"> |
| 154 | <ul> |
| 155 | {workspaces.map((membership) => ( |
| 156 | <li key={membership.slug} className="flex items-center gap-3 px-3.5 py-2.5"> |
| 157 | <span className="min-w-0 grow truncate text-sm">{membership.name || membership.slug}</span> |
| 158 | <Select |
| 159 | value={prefs.workspaces[membership.slug] ?? "default"} |
| 160 | onValueChange={(value) => void save({ workspaces: { [membership.slug]: value === "default" ? null : (value as NotifyLevel) } })} |
| 161 | > |
| 162 | <SelectTrigger size="sm" className="w-44" aria-label={`Notifications for ${membership.name || membership.slug}`}> |
| 163 | <SelectValue /> |
| 164 | </SelectTrigger> |
| 165 | <SelectContent> |
| 166 | <SelectItem value="default">As above</SelectItem> |
| 167 | <SelectItem value="all">Everything</SelectItem> |
| 168 | <SelectItem value="dms_mentions">DMs and mentions</SelectItem> |
| 169 | <SelectItem value="none">Nothing</SelectItem> |
| 170 | </SelectContent> |
| 171 | </Select> |
| 172 | </li> |
| 173 | ))} |
| 174 | </ul> |
| 175 | </Card> |
| 176 | </div> |
| 177 | )} |
| 178 | |
| 179 | <Card> |
| 180 | <div className="flex items-start gap-4 p-4"> |
| 181 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent"> |
| 182 | <BellRing size={16} /> |
| 183 | </span> |
| 184 | <div className="min-w-0 grow"> |
| 185 | <p className="text-sm font-medium text-fg">Browser notifications</p> |
| 186 | <p className="mt-0.5 text-xs text-muted" role="status"> |
| 187 | {DEVICE_LINE[device]} |
| 188 | </p> |
| 189 | </div> |
| 190 | <Switch |
| 191 | checked={deviceOn} |
| 192 | disabled={deviceLocked || busy === "device" || !status} |
| 193 | onCheckedChange={(on) => void toggleDevice(on)} |
| 194 | aria-label="Browser notifications" |
| 195 | /> |
| 196 | </div> |
| 197 | </Card> |
| 198 | |
| 199 | <div className="flex flex-wrap items-center gap-3"> |
| 200 | <Button type="button" variant="outline" onClick={() => void test()} disabled={busy === "test" || !status}> |
| 201 | <Send size={14} /> |
| 202 | {busy === "test" ? "Sending…" : "Send a test notification"} |
| 203 | </Button> |
| 204 | {said && <p className="text-xs text-muted">{said}</p>} |
| 205 | </div> |
| 206 | </section> |
| 207 | ); |
| 208 | } |