import { useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useActivityNotifications, type ActivityNotification } from '@/hooks/useActivityNotifications'; import { useCrewNotifications } from '@/hooks/useCrewNotifications'; import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useNewsletterSubscription } from '@/hooks/useNewsletterSubscription'; import { useDmEnvelopes, type DmEnvelope } from '@/hooks/useDmEnvelopes'; import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; import { useLocalReminders } from '@/hooks/useLocalReminders'; import { isNativePlatform } from '@/lib/platform'; import { getNotificationPermission, listenForNotificationTaps, showOsNotification, } from '@/lib/osNotify'; import type { NotificationCategory, NotificationPrefs } from '@/lib/notificationPrefs'; import type { CollectiveJoinRequest, CrewInvite, CrewJoinRequest, SkateCrew, SkateNewsletterIssue } from '@/lib/types'; /** * Turns the in-app activity the app already computes (bell, crews, DMs, * newsletter) into OS notifications — **without adding any new relay traffic or * any new network destination**. * * Design constraints, all deliberate: * - **Opt-in only.** The engine is not even mounted until the user enables * notifications, and nothing is requested from the OS until then. * - **Never decrypts.** DM notifications come from the encrypted gift-wrap * ENVELOPE, so an Amber user is never prompted to decrypt just to be told * they have a message. * - **No history replay.** Each source is "primed" silently the first time it * returns data; only items that arrive afterwards are ever announced. * - **Only when you're not looking.** A notification is suppressed while the * app/tab is visible — the bell already covers that case. * * Delivery is best-effort and local: a closed app/browser is not reachable * without a server-side push, which this intentionally is not. */ type Source = NotificationCategory; interface Candidate { /** Stable identity — event/wrap id, prefixed by kind. */ key: string; source: Source; createdAt: number; title: string; body: string; path: string; } interface SeenState { primed: Partial>; ids: string[]; } const SEEN_KEY = 'skatespots:notifications:seen'; const MAX_SEEN_IDS = 600; const MAX_PER_BURST = 3; function loadSeen(): SeenState { try { const raw = localStorage.getItem(SEEN_KEY); if (raw) { const parsed = JSON.parse(raw) as SeenState; if (Array.isArray(parsed?.ids)) { return { primed: parsed.primed ?? {}, ids: parsed.ids }; } } } catch { // Corrupt/blocked storage — start fresh. } return { primed: {}, ids: [] }; } function persistSeen(state: SeenState): void { try { localStorage.setItem( SEEN_KEY, JSON.stringify({ primed: state.primed, ids: state.ids.slice(-MAX_SEEN_IDS) }), ); } catch { // Storage full/blocked — notifications still work for this session. } } /** `/crew//` from a `35006:pubkey:identifier` coordinate. */ function coordPath(coord: string, route: string, fallback: string): string { const parts = coord.split(':'); if (parts.length >= 3 && parts[1] && parts[2]) return `/${route}/${parts[1]}/${parts[2]}`; return fallback; } function activityCandidate(n: ActivityNotification): Candidate { let title: string; switch (n.type) { case 'zap': title = n.amountSats ? `You got zapped ${n.amountSats.toLocaleString()} sats` : 'You got zapped'; break; case 'reaction': title = 'Someone liked your post'; break; case 'repost': title = 'Someone reposted you'; break; case 'mention': title = 'Someone mentioned you'; break; case 'reply': title = 'Someone replied to you'; break; case 'rsvp': title = "Someone RSVP'd to your event"; break; case 'event-update': title = "An event you're going to was updated"; break; default: title = 'New activity'; break; } return { key: `activity:${n.id}`, source: 'social', createdAt: n.createdAt, title, body: 'Tap to open SkateSpots', path: '/', }; } function buildCrewCandidates( invites: CrewInvite[], listedCrews: SkateCrew[], joinRequests: Array<{ request: CrewJoinRequest }>, collectiveRequests: CollectiveJoinRequest[], ): Candidate[] { const out: Candidate[] = []; for (const inv of invites) { out.push({ key: `crew-invite:${inv.id}`, source: 'crew', createdAt: inv.createdAt, title: 'Crew invite', body: `You're invited to ${inv.crewName}`, path: coordPath(inv.crewCoord, 'crew', '/crews'), }); } for (const crew of listedCrews) { out.push({ key: `crew-confirm:${crew.pubkey}:${crew.identifier}`, source: 'crew', createdAt: crew.createdAt ?? 0, title: "You've been added to a crew", body: `${crew.name} — confirm to join`, path: `/crew/${crew.pubkey}/${crew.identifier}`, }); } for (const { request } of joinRequests) { out.push({ key: `crew-join:${request.id}`, source: 'crew', createdAt: request.createdAt, title: 'Join request', body: `Someone wants to join ${request.crewName}`, path: coordPath(request.crewCoord, 'crew', '/crews'), }); } for (const req of collectiveRequests) { out.push({ key: `crew-collective:${req.id}`, source: 'crew', createdAt: req.createdAt, title: 'Join request', body: `Someone wants to join ${req.collectiveName}`, path: '/crews', }); } return out; } function dmCandidate(envelope: DmEnvelope): Candidate { return { key: `dm:${envelope.id}`, source: 'dms', createdAt: envelope.createdAt, title: 'New direct message', body: 'Tap to read it', path: '/messages', }; } function newsletterCandidate(issue: SkateNewsletterIssue): Candidate { return { key: `newsletter:${issue.id}`, source: 'newsletter', createdAt: issue.publishedAt, title: issue.title, body: 'New SkateSpots drop', path: '/newsletter', }; } async function dispatchNotifications(items: Candidate[]): Promise { const permission = await getNotificationPermission(); if (permission !== 'granted') return; const sorted = [...items].sort((a, b) => b.createdAt - a.createdAt); const shown = sorted.slice(0, MAX_PER_BURST); for (const candidate of shown) { await showOsNotification({ id: candidate.key, title: candidate.title, body: candidate.body, path: candidate.path, }); } if (sorted.length > shown.length) { await showOsNotification({ id: `summary:${Date.now()}`, title: `${sorted.length - shown.length} more updates`, body: 'Open SkateSpots to catch up', path: '/', }); } } /** * Mounted once in `Layout`. Renders nothing; the inner engine only exists once * notifications are enabled, so disabling them costs zero queries. */ export function OsNotificationBridge() { const { user } = useCurrentUser(); const [prefs] = useNotificationPrefs(); if (!user || !prefs.enabled) return null; return ; } function NotificationEngine({ prefs }: { prefs: NotificationPrefs }) { const navigate = useNavigate(); const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); const { invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } = useCrewNotifications(); const { data: issues = [], isLoading: newsletterLoading } = useNewsletterIssues(); const { isSubscribed } = useNewsletterSubscription(); const { data: envelopes = [], isFetched: dmFetched } = useDmEnvelopes(true); // Loaded lazily and mutated directly (never state) so bookkeeping can't cause // a render loop. const seenRef = useRef(null); if (seenRef.current === null) seenRef.current = loadSeen(); // Native notification taps → in-app navigation. No-op on the web. useEffect(() => { let unsubscribe: (() => void) | undefined; let cancelled = false; void listenForNotificationTaps((path) => navigate(path)).then((fn) => { if (cancelled) fn(); else unsubscribe = fn; }); return () => { cancelled = true; unsubscribe?.(); }; }, [navigate]); const socialItems = useMemo(() => activity.map(activityCandidate), [activity]); const crewItems = useMemo( () => buildCrewCandidates(invites, listedCrews, joinRequests, collectiveRequests), [invites, listedCrews, joinRequests, collectiveRequests], ); const newsletterItems = useMemo( () => (isSubscribed ? issues.map(newsletterCandidate) : []), [issues, isSubscribed], ); const dmItems = useMemo(() => envelopes.map(dmCandidate), [envelopes]); useEffect(() => { const seen = seenRef.current; if (!seen) return; const sources: Array<{ name: Source; ready: boolean; items: Candidate[] }> = [ { name: 'social', ready: !activityLoading, items: socialItems }, { name: 'crew', ready: !crewLoading, items: crewItems }, { name: 'newsletter', ready: !newsletterLoading, items: newsletterItems }, { name: 'dms', ready: dmFetched, items: dmItems }, ]; let changed = false; const fresh: Candidate[] = []; for (const source of sources) { // Wait for this source's first real result — priming on an empty default // would make the eventual data look "new" and flood the shade. if (!source.ready) continue; if (!seen.primed[source.name]) { seen.primed[source.name] = true; for (const item of source.items) seen.ids.push(item.key); changed = true; continue; } const seenSet = new Set(seen.ids); for (const item of source.items) { if (seenSet.has(item.key)) continue; seenSet.add(item.key); seen.ids.push(item.key); fresh.push(item); changed = true; } } if (changed) persistSeen(seen); if (fresh.length === 0) return; // If the user is looking at the app, the bell already told them. if (typeof document !== 'undefined' && document.visibilityState === 'visible') return; const allowed = fresh.filter((item) => prefs[item.source]); if (allowed.length === 0) return; void dispatchNotifications(allowed); }, [ socialItems, crewItems, newsletterItems, dmItems, activityLoading, crewLoading, newsletterLoading, dmFetched, prefs, ]); // Scheduled reminders are native-only (the web has no scheduler), so only // mount the hook — and therefore only run its event/RSVP queries — when the // platform can actually use them. return prefs.reminders && isNativePlatform() ? : null; } function LocalReminders() { useLocalReminders(); return null; }