- 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.
288 lines
12 KiB
TypeScript
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'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's push
|
|
service. It sees only <em>that</em> your key has something — never who sent it or
|
|
what it says, and it can'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't be reached, so alerts arrive next time you open SkateSpots.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|