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).
171 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|