SkateSpots/src/components/NotificationSettingsCard.tsx
SkateSpots 63dc4bee7d feat(notify): opt-in on-device OS notifications (Phase 1)
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.

- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
  uses @capacitor/local-notifications (lazily imported, so the web bundle never
  pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
  opted in. Per-source silent priming + a seen-id set means history is never
  replayed as a flood, and it stays quiet while the app is visible (the bell
  already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
  no decryption) — so Amber is never prompted just to be told you have a
  message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
  default off, per-category toggles. Settings -> Notifications UI.

Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
2026-09-29 12:59:13 +07:00

171 lines
6.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Bell, Loader2, ShieldCheck, TriangleAlert } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
import {
getNotificationPermission,
requestNotificationPermission,
showOsNotification,
type OsPermission,
} from '@/lib/osNotify';
/**
* Notification settings — the opt-in surface for OS notifications.
*
* Turns on nothing until the user asks, and explains the one honest limitation
* up front: notifications are delivered by the device itself while the app is
* running (e.g. a background browser tab). A fully closed app needs a push
* server, which SkateSpots deliberately does not run.
*/
export function NotificationSettingsCard() {
const [prefs, setPrefs] = useNotificationPrefs();
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void getNotificationPermission().then((result) => {
if (!cancelled) setPermission(result);
});
return () => {
cancelled = true;
};
}, []);
const enable = async () => {
setBusy(true);
try {
const result = await requestNotificationPermission();
setPermission(result);
if (result === 'granted') {
setPrefs((prev) => ({ ...prev, enabled: true }));
}
} finally {
setBusy(false);
}
};
const toggleEnabled = (next: boolean) => {
if (!next) {
setPrefs((prev) => ({ ...prev, enabled: false }));
return;
}
if (permission === 'granted') {
setPrefs((prev) => ({ ...prev, enabled: true }));
return;
}
void enable();
};
const setCategory = (key: (typeof NOTIFICATION_CATEGORIES)[number]['key'], value: boolean) => {
setPrefs((prev) => ({ ...prev, [key]: value }));
};
const sendTest = () => {
void showOsNotification({
id: `test:${Date.now()}`,
title: 'SkateSpots notifications are on 🛹',
body: 'This is how a DM, zap or crew invite will look.',
path: '/',
});
};
const unsupported = permission === 'unsupported';
const denied = permission === 'denied';
const granted = permission === 'granted';
return (
<div className="space-y-4">
{/* Master switch */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="flex items-center gap-3 px-4 py-3.5">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-[#ccff00]/15">
<Bell className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Enable notifications</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Buzz this device when something needs you.
</p>
</div>
<Switch
checked={prefs.enabled && granted}
disabled={unsupported || busy}
onCheckedChange={toggleEnabled}
/>
</div>
{permission === 'loading' && (
<p className="flex items-center gap-2 border-t border-white/5 px-4 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Checking permission…
</p>
)}
{unsupported && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
This device doesn't support notifications here. Rebuild the Android app to
enable them on your phone.
</p>
)}
{denied && (
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs text-amber-400">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
Notifications are blocked for this site. Allow them in your browser/OS
settings, then try again.
</p>
)}
</div>
{/* Per-category toggles */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<p className="border-b border-white/5 px-4 py-2.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">
Notify me about
</p>
{NOTIFICATION_CATEGORIES.map(({ key, label, hint }) => (
<div
key={key}
className="flex items-center gap-3 border-b border-white/5 px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">{label}</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">{hint}</p>
</div>
<Switch
checked={prefs.enabled && granted && prefs[key]}
disabled={!prefs.enabled || !granted}
onCheckedChange={(value) => setCategory(key, value)}
/>
</div>
))}
</div>
{/* Test + privacy note */}
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="px-4 py-3.5">
<Button
variant="outline"
size="sm"
onClick={sendTest}
disabled={!prefs.enabled || !granted}
className="w-full"
>
Send a test notification
</Button>
</div>
<p className="flex items-start gap-2 border-t border-white/5 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<span>
Notifications are built on your device from data the app already has. Nothing
extra is sent anywhere, and direct-message alerts never decrypt your messages —
so Amber is never asked to open one. While the app is fully closed Android and
iOS can't reach it, so those alerts arrive next time you open SkateSpots.
</span>
</p>
</div>
</div>
);
}