SkateSpots/src/components/NotificationSettingsCard.tsx
SkateSpots 9155949fda feat(push): extend categories to crew + social; master switch tears down push
- CATEGORY_BY_KIND now maps crew invite (36229) / join-request (31969) -> crew and
  zap (9735) / reply-mention (1) / comment (1111) -> social; the relay watch
  includes those kinds. Reactions/reposts are deliberately not pushed (noisy) and
  newsletter has no push channel.
- Master switch off now also disables web push and the native poller.
- Verified: a crew-only token is not woken by a zap but is by a crew invite.
2026-10-01 20:20:10 +07:00

288 lines
12 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Bell, Loader2, Radio, ShieldCheck, TriangleAlert } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { useBackgroundDelivery } from '@/hooks/useBackgroundDelivery';
import { useToast } from '@/hooks/useToast';
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);
const push = usePushNotifications();
const background = useBackgroundDelivery();
const { toast } = useToast();
// When the category choices change, re-send them to whatever channel is on, so
// a closed-app push respects the toggles (the server filters by category).
const categoriesKey = push.categories.join(',');
const firstCategoriesRun = useRef(true);
useEffect(() => {
if (firstCategoriesRun.current) {
firstCategoriesRun.current = false;
return;
}
if (push.state === 'on') void push.resubscribe();
if (background.mode !== 'off') void background.resubscribe();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoriesKey]);
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 }));
// One kill switch: turning the master off also stops push + the native poller.
void push.disable();
void background.disable();
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>
{/* Background push (closed-app) */}
<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">
<Radio className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Background notifications</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Get pinged for messages and trades even when SkateSpots is closed.
</p>
</div>
{push.state === 'working' ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Switch
checked={push.state === 'on'}
disabled={push.state === 'unsupported'}
onCheckedChange={(value) => {
if (value) void push.enable();
else void push.disable();
}}
/>
)}
</div>
{push.state === '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 browser doesn&apos;t support background push. On mobile, add SkateSpots to your home screen.
</p>
)}
{push.state === 'error' && push.error && (
<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" />
{push.error}
</p>
)}
<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>
To reach you while closed, a SkateSpots notification server gets a ping from your
relay when something arrives for your key and passes it to your phone&apos;s push
service. It sees only <em>that</em> your key has something — never who sent it or
what it says, and it can&apos;t decrypt your messages. Opt in per device.
</span>
</p>
</div>
{/* Background delivery (Android only — no Google services). */}
{background.supported && (
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
<div className="px-4 py-3.5">
<p className="text-sm font-bold text-white">Background delivery (Android)</p>
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
Check for new messages, offers and trades while the app is closed — no Google services.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant={background.mode === 'off' ? 'default' : 'outline'} onClick={() => void background.disable()}>
Off
</Button>
<Button
size="sm"
variant={background.mode === 'standard' ? 'default' : 'outline'}
onClick={() => {
void background.enable('standard').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
});
}}
>
Standard (~15 min)
</Button>
<Button
size="sm"
variant={background.mode === 'fast' ? 'default' : 'outline'}
onClick={() => {
void background.enable('fast').catch((e) => {
toast({ title: 'Could not enable', description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive' });
});
}}
>
Fast (~5 min)
</Button>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">
Standard is best-effort — Android may defer it under Doze. Fast shows a persistent
notification and uses more battery. Nothing is decrypted on our server.
</p>
</div>
</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>
Alerts are built on your device from data the app already has, and direct-message
alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
<strong className="text-white">Background notifications</strong> off, a fully closed
app can&apos;t be reached, so alerts arrive next time you open SkateSpots.
</span>
</p>
</div>
</div>
);
}