Files
MealQuest/web/src/settings/NotificationsPage.tsx
T
2026-10-09 15:19:36 -04:00

482 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import { Fragment, useEffect, useId, useState } from 'react'
import { useNavigate } from 'react-router'
import { InstallHint } from '../pwa/InstallHint.tsx'
import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts'
import { usePush } from '../push/usePush.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { Toggle } from '../ui/Toggle.tsx'
import { clockTime, formatClockTime } from './api.ts'
import { LeaveGuard } from './LeaveGuard.tsx'
import {
PUSH_STATUS_KEY,
useChatWebhook,
useNotificationSettings,
usePushStatus,
useSaveChatWebhook,
useSaveNotificationSettings,
useTestChatWebhook,
type ChatWebhook,
type NotificationEvent,
type NotificationSettings,
} from './notificationsApi.ts'
import styles from './NotificationsPage.module.css'
// eventLabels names each event as the Notifications screen lists it.
// rotation_review is the next-day Rotation check.
const eventLabels: Record<NotificationEvent, string> = {
plan_ready: 'New weekly plan ready',
list_ready: 'Shopping list ready',
day_of_reminder: 'Day-of dinner reminder',
new_suggestions: 'New suggestions to swipe',
rotation_review: 'Keep it in rotation?',
}
// Notifications & Google Chat (handoff mock 1n): which events go by push
// and to Google Chat, when the day-of reminder is sent, Web Push on this
// device, and the household's Google Chat webhook. Each change is saved as
// it is made; only a webhook being replaced is a draft.
export function NotificationsPage() {
const [dirty, setDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
<NotificationPrefs />
<ChatWebhookCard onDirtyChange={setDirty} />
<ChatPreview />
<LeaveGuard dirty={dirty} />
</div>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<h1 className={styles.title}>Notifications</h1>
</header>
)
}
// NotificationsCard is the notification preferences as a card, for the
// desktop settings (handoff mock 2e).
export function NotificationsCard() {
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Notifications
</h2>
<NotificationPrefs />
</Card>
)
}
// NotificationPrefs is the event × channel matrix once it loads, then Web
// Push on this device.
function NotificationPrefs() {
const settings = useNotificationSettings()
return (
<>
{settings.isPending && <Skeleton className={styles.loading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Notification settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <NotificationMatrix saved={settings.data} />}
<PushPermission />
<InstallHint />
</>
)
}
type Channel = 'push' | 'chat'
const channelNames: Record<Channel, string> = { push: 'Push', chat: 'Google Chat' }
// NotificationMatrix saves every change at once. While a save is pending it
// shows the settings being saved and takes no other change; a failed save
// shows the stored settings again and says why.
function NotificationMatrix({ saved }: { saved: NotificationSettings }) {
const save = useSaveNotificationSettings()
const [editingTime, setEditingTime] = useState(false)
const shown = save.isPending ? save.variables : saved
const id = useId()
const reminder = formatClockTime(shown.reminder_time)
function toggle(event: NotificationEvent, channel: Channel, on: boolean) {
save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) })
}
return (
<>
<div className={styles.matrix} role="group" aria-label="Notification channels">
<span className={styles.head}>Event</span>
<span className={styles.head} aria-hidden="true">
Push
</span>
<span className={styles.head} aria-hidden="true">
Chat
</span>
{shown.prefs.map(({ event, push, chat }) => (
<Fragment key={event}>
<div className={styles.event}>
<span id={`${id}-${event}`} className={styles.eventName}>
{eventLabels[event]}
</span>
{event === 'day_of_reminder' && (
<button
type="button"
className={styles.time}
aria-label={`Reminder time, ${reminder}. Change`}
disabled={save.isPending}
onClick={() => {
save.reset()
setEditingTime(true)
}}
>
at {reminder}
</button>
)}
</div>
{(['push', 'chat'] as const).map((channel) => (
<Toggle
key={channel}
className={styles.toggle}
aria-label={`${eventLabels[event]}: ${channelNames[channel]}`}
checked={channel === 'push' ? push : chat}
disabled={save.isPending}
onCheckedChange={(on) => toggle(event, channel, on)}
/>
))}
</Fragment>
))}
</div>
{save.isError && !editingTime && (
<ErrorBanner onRetry={() => save.mutate(save.variables)}>
Notification settings were not saved: {save.error.message}
</ErrorBanner>
)}
<ReminderTimeSheet
open={editingTime}
saved={saved}
busy={save.isPending}
error={save.isError ? save.error.message : null}
onClose={() => {
save.reset()
setEditingTime(false)
}}
onSave={(reminder_time) =>
save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) })
}
/>
</>
)
}
type ReminderTimeSheetProps = {
open: boolean
saved: NotificationSettings
busy: boolean
// Why the last save failed, or null.
error: string | null
onClose: () => void
onSave: (reminderTime: string) => void
}
// ReminderTimeSheet edits when the day-of dinner reminder is sent.
function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) {
const titleId = useId()
const [time, setTime] = useState(saved.reminder_time)
useEffect(() => {
if (open) setTime(saved.reminder_time)
}, [open, saved.reminder_time])
const valid = clockTime.test(time)
return (
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div className={styles.sheet}>
<h2 id={titleId} className={styles.sheetTitle}>
Day-of dinner reminder
</h2>
<p className={styles.sheetText}>When the reminder is sent on each planned day. This changes it for everyone in the household.</p>
<TextInput
label="Reminder time"
type="time"
value={time}
required
onChange={(event) => setTime(event.target.value)}
/>
{!valid && <p className={styles.note}>Choose a time.</p>}
{error !== null && (
<p className={styles.error} role="alert">
The reminder time was not saved: {error}
</p>
)}
<div className={styles.sheetActions}>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button disabled={busy || !valid || time === saved.reminder_time} onClick={() => onSave(time)}>
{busy ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
</Sheet>
)
}
const pushStatusText: Record<PushStatus, string> = {
on: 'On',
off: 'Off',
denied: 'Blocked',
unsupported: 'Not supported',
}
type PushOutcome = SubscribePushResult | UnsubscribePushResult
// PushPermission is whether Web Push is on for this device, with a button
// to turn it on or off. Turning on asks for notification permission, which
// browsers allow only from a tap, so subscribePush is called straight from
// the button's click.
function PushPermission() {
const status = usePushStatus()
const push = usePush()
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [outcome, setOutcome] = useState<PushOutcome | null>(null)
const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null)
const labelId = useId()
function run(action: 'on' | 'off', pending: Promise<PushOutcome>) {
setBusy(true)
setOutcome(null)
setFailure(null)
void pending
.then(setOutcome, (error: unknown) =>
setFailure({ action, message: error instanceof Error ? error.message : String(error) }),
)
.finally(() => {
setBusy(false)
void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY })
})
}
return (
<div className={styles.permission}>
<div className={styles.permissionRow} role="group" aria-labelledby={labelId}>
<p className={styles.permissionText}>
<span id={labelId}>Notifications on this device</span>
{': '}
{status.isPending && <Skeleton className={styles.statusSkeleton} />}
{status.isSuccess && <strong>{pushStatusText[status.data]}</strong>}
</p>
{status.data === 'off' && (
<Button variant="outline" disabled={busy} onClick={() => run('on', push.subscribePush())}>
{busy ? 'Turning on…' : 'Turn on'}
</Button>
)}
{status.data === 'on' && (
<Button variant="outline" disabled={busy} onClick={() => run('off', push.unsubscribePush())}>
{busy ? 'Turning off…' : 'Turn off'}
</Button>
)}
</div>
{status.data === 'denied' && (
<p className={styles.note}>This browser blocks notifications from MealQuest. Allow them in the browser's site settings.</p>
)}
{status.data === 'unsupported' && (
<p className={styles.note}>This browser can't receive notifications.</p>
)}
{outcome === 'dismissed' && (
<p className={styles.note} role="status">
The permission prompt was closed without an answer, so notifications are still off.
</p>
)}
{status.isError && (
<ErrorBanner onRetry={() => void status.refetch()}>
Could not check notifications on this device: {status.error.message}
</ErrorBanner>
)}
{failure !== null && (
<ErrorBanner>
Notifications could not be turned {failure.action}: {failure.message}
</ErrorBanner>
)}
</div>
)
}
type ChatWebhookCardProps = {
// Called with whether a webhook being entered is unsaved.
onDirtyChange: (dirty: boolean) => void
}
// ChatWebhookCard is the household's Google Chat webhook: its masked URL,
// the space it posts to, and a test message.
export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) {
const webhook = useChatWebhook()
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Google Chat webhook
</h2>
{webhook.isPending && <Skeleton className={styles.cardLoading} />}
{webhook.isError && (
<ErrorBanner onRetry={() => void webhook.refetch()}>
The Google Chat webhook could not load: {webhook.error.message}
</ErrorBanner>
)}
{webhook.isSuccess && <ChatWebhookForm saved={webhook.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) {
const connected = saved.url_masked !== null
const [replacing, setReplacing] = useState(!connected)
const [url, setUrl] = useState('')
const [space, setSpace] = useState('')
const save = useSaveChatWebhook()
const test = useTestChatWebhook()
const dirty = replacing && (url !== '' || space !== '')
useEffect(() => {
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function close() {
save.reset()
setReplacing(false)
setUrl('')
setSpace('')
}
if (replacing) {
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<TextInput
label="Webhook URL"
type="url"
className={`${styles.input} ${styles.mono}`}
value={url}
placeholder="https://chat.googleapis.com/v1/spaces/…"
autoComplete="off"
spellCheck={false}
onChange={(event) => setUrl(event.target.value)}
/>
<TextInput
label="Space name"
className={styles.input}
value={space}
placeholder="Dinner Crew"
autoComplete="off"
onChange={(event) => setSpace(event.target.value)}
/>
{save.isError && (
<p className={styles.error} role="alert">
The webhook was not saved: {save.error.message}
</p>
)}
<div className={styles.webhookActions}>
{connected && (
<Button variant="outline" onClick={close}>
Cancel
</Button>
)}
<Button
disabled={url.trim() === '' || space.trim() === '' || save.isPending}
onClick={() => {
test.reset()
save.mutate({ url: url.trim(), space_label: space.trim() }, { onSuccess: close })
}}
>
{save.isPending ? 'Saving…' : 'Save webhook'}
</Button>
</div>
</fieldset>
)
}
return (
<>
<TextInput
label="Webhook URL"
labelHidden
className={`${styles.input} ${styles.mono}`}
value={saved.url_masked ?? ''}
readOnly
/>
<TestResult test={test} />
<div className={styles.webhookRow}>
<p className={styles.connected}>Connected · {saved.space_label}</p>
<Button variant="outline" disabled={test.isPending} onClick={() => test.mutate()}>
{test.isPending ? 'Sending…' : 'Send test'}
</Button>
</div>
<button
type="button"
className={styles.link}
onClick={() => {
test.reset()
setReplacing(true)
}}
>
Replace webhook
</button>
</>
)
}
// TestResult says how the last Send test ended, under the URL: sent, the
// HTTP status Google Chat refused it with, or why it was not sent.
function TestResult({ test }: { test: ReturnType<typeof useTestChatWebhook> }) {
if (test.isSuccess) {
const status = test.data.http_status
const ok = status >= 200 && status < 300
return (
<p className={ok ? styles.testOk : styles.error} role="status">
{ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`}
</p>
)
}
if (test.isError) {
return (
<p className={styles.error} role="alert">
Test message not sent: {test.error.message}
</p>
)
}
return null
}
// ChatPreview shows what a Google Chat message from MealQuest looks like.
function ChatPreview() {
return (
<figure className={styles.preview} aria-label="Google Chat message preview">
<span className={styles.avatar} aria-hidden="true">
Q
</span>
<p className={styles.previewText}>
<strong>MealQuest</strong> · preview
<br />
This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →
</p>
</figure>
)
}