From 63dc4bee7dda184194bfa610dd6cda3d0b90b506 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 29 Sep 2026 12:59:13 +0700 Subject: [PATCH] feat(notify): opt-in on-device OS notifications (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- AGENTS.md | 20 ++ package-lock.json | 10 + package.json | 1 + src/components/Layout.tsx | 4 + src/components/NotificationSettingsCard.tsx | 171 ++++++++++ src/components/OsNotificationBridge.tsx | 335 ++++++++++++++++++++ src/hooks/useActivityNotifications.ts | 1 + src/hooks/useDirectMessages.ts | 2 +- src/hooks/useDmEnvelopes.ts | 60 ++++ src/hooks/useNotificationPrefs.ts | 30 ++ src/lib/notificationPrefs.ts | 47 +++ src/lib/osNotify.ts | 162 ++++++++++ src/pages/SettingsPage.tsx | 20 +- 13 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 src/components/NotificationSettingsCard.tsx create mode 100644 src/components/OsNotificationBridge.tsx create mode 100644 src/hooks/useDmEnvelopes.ts create mode 100644 src/hooks/useNotificationPrefs.ts create mode 100644 src/lib/notificationPrefs.ts create mode 100644 src/lib/osNotify.ts diff --git a/AGENTS.md b/AGENTS.md index d3b8fba..8a0041d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -537,6 +537,26 @@ session with **no other memory of this project**. So: cluster refresh per batch, pins paint progressively, main thread stays free. **If you change what a marker renders, add it to the signature** or the update will be skipped. `showSpots/showShops/showGear` are now effect deps. +- **On-device OS notifications (Phase 1, 2026-09-29):** + `src/components/OsNotificationBridge.tsx` (mounted once in `Layout`, inert until + opted in) mirrors the in-app activity — social (`useActivityNotifications`), + crews (`useCrewNotifications`), newsletter (`useNewsletterIssues`) and **DM + gift-wrap ENVELOPES (`useDmEnvelopes`, no decrypt)** — to the OS shade. + Delivery is `src/lib/osNotify.ts`: web `Notification` API, native + **`@capacitor/local-notifications`** (lazily imported; the dep was added so + `cap sync` registers it — an installed APK must be rebuilt to pick it up). + Prefs are **device-local** (`useNotificationPrefs`, key + `skatespots:notifications:prefs`, opt-in, default **OFF**); UI is + **Settings → Notifications** (`NotificationSettingsCard`). Hard rules, don't + regress: **(1) never decrypt to notify** — DM alerts are envelope-only, so + Amber is never prompted just to say "you have a message"; **(2) per-source + silent PRIMING + a seen-id set** (`skatespots:notifications:seen`, `×600`) so + opening the app never replays history as a flood; **(3) suppressed while the + document is visible** (the bell already covers that). Delivery is **local + only** — a fully closed app/browser is unreachable without a server push. + **Phase 2/3 (NOT built):** background runner (on-device polling, closed-app, + ~15 min floor) and a content-free FCM "doorbell" (timely, but a server learns + metadata). `useResolvedDmRelays` is exported for the envelope hook. - **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the existing "No spots match" card is for *filters* (`filteredSpots.length === 0 && allLocations.length > 0`) and a new "No spots mapped yet — be the first" card diff --git a/package-lock.json b/package-lock.json index 041436f..85ae61f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@capacitor/camera": "^8.2.1", "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", + "@capacitor/local-notifications": "^8.3.1", "@cashu/cashu-ts": "2.8.1", "@contextvm/sdk": "^0.12.0", "@fontsource-variable/inter": "^5.2.6", @@ -379,6 +380,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/local-notifications": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.3.1.tgz", + "integrity": "sha512-F7zu/+p/mIR958YP4axOkeI5hBKRaXDyYjbNPrAil2wWfd7OxScPSTvP9t85NpQ0eULVRu0/sntoxOg6ck477A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/synapse": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", diff --git a/package.json b/package.json index a6dbe2d..549b1d1 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@capacitor/camera": "^8.2.1", "@capacitor/core": "^8.4.2", "@capacitor/geolocation": "^8.2.0", + "@capacitor/local-notifications": "^8.3.1", "@cashu/cashu-ts": "2.8.1", "@contextvm/sdk": "^0.12.0", "@fontsource-variable/inter": "^5.2.6", diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2fa52a8..ad41172 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -53,6 +53,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { NewsletterOverlay } from '@/components/NewsletterOverlay'; import { NotificationBell } from '@/components/NotificationBell'; +import { OsNotificationBridge } from '@/components/OsNotificationBridge'; // Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why // (keeps the Cashu stack out of the initial bundle). @@ -1147,6 +1148,9 @@ export function Layout({ children, noPadding }: LayoutProps) { {/* Newsletter: floating pill + Monday admin reminder */} + + {/* Mirrors in-app activity to OS notifications — inert until opted in. */} + ); } diff --git a/src/components/NotificationSettingsCard.tsx b/src/components/NotificationSettingsCard.tsx new file mode 100644 index 0000000..62c4e4d --- /dev/null +++ b/src/components/NotificationSettingsCard.tsx @@ -0,0 +1,171 @@ +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('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 ( +
+ {/* Master switch */} +
+
+
+ +
+
+

Enable notifications

+

+ Buzz this device when something needs you. +

+
+ +
+ + {permission === 'loading' && ( +

+ Checking permission… +

+ )} + + {unsupported && ( +

+ + This device doesn't support notifications here. Rebuild the Android app to + enable them on your phone. +

+ )} + + {denied && ( +

+ + Notifications are blocked for this site. Allow them in your browser/OS + settings, then try again. +

+ )} +
+ + {/* Per-category toggles */} +
+

+ Notify me about +

+ {NOTIFICATION_CATEGORIES.map(({ key, label, hint }) => ( +
+
+

{label}

+

{hint}

+
+ setCategory(key, value)} + /> +
+ ))} +
+ + {/* Test + privacy note */} +
+
+ +
+

+ + + 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. + +

+
+
+ ); +} diff --git a/src/components/OsNotificationBridge.tsx b/src/components/OsNotificationBridge.tsx new file mode 100644 index 0000000..b8c4ebe --- /dev/null +++ b/src/components/OsNotificationBridge.tsx @@ -0,0 +1,335 @@ +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 { + 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, + ]); + + return null; +} diff --git a/src/hooks/useActivityNotifications.ts b/src/hooks/useActivityNotifications.ts index 18c4f79..f60392a 100644 --- a/src/hooks/useActivityNotifications.ts +++ b/src/hooks/useActivityNotifications.ts @@ -221,6 +221,7 @@ export function useActivityNotifications() { return { notifications: query.data ?? [], isLoading: query.isLoading, + isFetched: query.isFetched, }; } diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index cff34ea..7a0fd08 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -64,7 +64,7 @@ const INBOX_DISCOVERY_RELAYS = [ * NOT in a user's NIP-65 list. Reading the user's own 10050 at query time makes * the inbox correct even when the stored config is wrong. (Fixed 2026-09-28.) */ -function useResolvedDmRelays(): { relays: string[]; key: string } { +export function useResolvedDmRelays(): { relays: string[]; key: string } { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { config } = useAppContext(); diff --git a/src/hooks/useDmEnvelopes.ts b/src/hooks/useDmEnvelopes.ts new file mode 100644 index 0000000..180bc76 --- /dev/null +++ b/src/hooks/useDmEnvelopes.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useResolvedDmRelays } from '@/hooks/useDirectMessages'; + +/** + * Lightweight DM "envelope" watcher — the id and timestamp of every NIP-17 gift + * wrap addressed to the user, **without decrypting anything**. + * + * This is what makes DM notifications possible without ever prompting Amber: + * the seal/rumor are encrypted, so we only see that *something* arrived. No + * sender, no content, no signer round-trip. The preview is intentionally generic + * ("New direct message") — the honest ceiling when we refuse to decrypt in the + * background. + * + * NIP-17 randomises the outer wrap timestamp (up to 2 days in the past), so we + * cannot reliably tell "new" from `created_at`. Callers should diff on the + * stable `id` instead. + */ + +const GIFT_WRAP_KIND = 1059; + +export interface DmEnvelope { + id: string; + createdAt: number; +} + +export function useDmEnvelopes(enabled: boolean) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { relays, key } = useResolvedDmRelays(); + + return useQuery({ + queryKey: ['dm-envelopes', user?.pubkey, key], + enabled: enabled && !!user?.pubkey && relays.length > 0, + staleTime: 60_000, + refetchInterval: 90_000, + queryFn: async (c): Promise => { + const pubkey = user!.pubkey; + const batches = await Promise.all( + relays.map(async (relayUrl) => { + try { + const relay = nostr.relay(relayUrl); + return await relay.query( + [{ kinds: [GIFT_WRAP_KIND], '#p': [pubkey], limit: 60 }], + { signal: AbortSignal.any([c.signal, AbortSignal.timeout(8000)]) }, + ); + } catch { + // One relay failing must not stop the others. + return []; + } + }), + ); + + const unique = new Map(); + for (const event of batches.flat()) unique.set(event.id, event.created_at); + return [...unique].map(([id, createdAt]) => ({ id, createdAt })); + }, + }); +} diff --git a/src/hooks/useNotificationPrefs.ts b/src/hooks/useNotificationPrefs.ts new file mode 100644 index 0000000..7ba2daf --- /dev/null +++ b/src/hooks/useNotificationPrefs.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { + DEFAULT_NOTIFICATION_PREFS, + NOTIFICATION_PREFS_KEY, + type NotificationPrefs, +} from '@/lib/notificationPrefs'; + +/** + * Read/write the device-local notification preferences. + * + * Returns a memoized object (stable identity until the stored value changes) so + * callers can safely put it in effect dependency arrays — an object literal that + * is fresh every render would otherwise re-run those effects on every render. + * Missing keys fall back to the defaults, so adding a new category later does + * not leave existing users with `undefined`. + */ +export function useNotificationPrefs() { + const [stored, setPrefs] = useLocalStorage( + NOTIFICATION_PREFS_KEY, + DEFAULT_NOTIFICATION_PREFS, + ); + + const prefs = useMemo( + () => ({ ...DEFAULT_NOTIFICATION_PREFS, ...(stored ?? {}) }), + [stored], + ); + + return [prefs, setPrefs] as const; +} diff --git a/src/lib/notificationPrefs.ts b/src/lib/notificationPrefs.ts new file mode 100644 index 0000000..05c14c3 --- /dev/null +++ b/src/lib/notificationPrefs.ts @@ -0,0 +1,47 @@ +/** + * On-device notification preferences. + * + * These are deliberately **device-local** (localStorage, never synced): whether + * YOUR phone busses is a property of that phone, not of your Nostr identity, and + * different devices legitimately want different settings. + * + * Notifications are strictly **opt-in** — `enabled` defaults to `false` and + * nothing is requested from the OS until the user turns them on in Settings. + */ + +export type NotificationCategory = 'dms' | 'crew' | 'social' | 'newsletter'; + +export interface NotificationPrefs { + /** Master switch. Off until the user explicitly enables notifications. */ + enabled: boolean; + /** Direct messages (derived from the encrypted gift-wrap ENVELOPE — never decrypted to notify). */ + dms: boolean; + /** Crew invites, join requests and membership confirmations. */ + crew: boolean; + /** Reactions, reposts, zaps, mentions, replies, RSVPs and event updates. */ + social: boolean; + /** New SkateSpots newsletter drops (subscribers only). */ + newsletter: boolean; +} + +export const NOTIFICATION_PREFS_KEY = 'skatespots:notifications:prefs'; + +export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { + enabled: false, + dms: true, + crew: true, + social: true, + newsletter: true, +}; + +/** Display metadata for the per-category toggles, in order. */ +export const NOTIFICATION_CATEGORIES: Array<{ + key: NotificationCategory; + label: string; + hint: string; +}> = [ + { key: 'dms', label: 'Direct messages', hint: 'Someone sends you a private message' }, + { key: 'crew', label: 'Crews', hint: 'Invites, join requests and membership confirmations' }, + { key: 'social', label: 'Zaps, replies & mentions', hint: 'Reactions, reposts, zaps, replies and event updates' }, + { key: 'newsletter', label: 'Newsletter', hint: 'New SkateSpots drops (subscribers only)' }, +]; diff --git a/src/lib/osNotify.ts b/src/lib/osNotify.ts new file mode 100644 index 0000000..c6e58aa --- /dev/null +++ b/src/lib/osNotify.ts @@ -0,0 +1,162 @@ +import { isNativePlatform } from '@/lib/platform'; + +/** + * A single, platform-agnostic way to put something on the OS notification shade. + * + * - **Native (Android/iOS via Capacitor):** `@capacitor/local-notifications`. + * The plugin is imported LAZILY so the web bundle never pays for it. + * - **Web:** the Notifications API (`new Notification(...)`). This only works + * while the page is alive — a background browser tab is exactly the case it + * is for. A closed tab/app needs a server push, which this deliberately is not. + * + * Nothing here contacts the network. The only payload is text already computed + * on the device; no pubkey, no message content, no location is sent anywhere. + */ + +export type OsPermission = 'granted' | 'denied' | 'prompt' | 'unsupported'; + +type NativeDisplay = 'granted' | 'denied' | 'prompt' | 'prompt-with-rationale'; + +function mapNative(display: NativeDisplay): OsPermission { + if (display === 'granted') return 'granted'; + if (display === 'denied') return 'denied'; + return 'prompt'; +} + +function mapWeb(permission: NotificationPermission): OsPermission { + if (permission === 'granted') return 'granted'; + if (permission === 'denied') return 'denied'; + return 'prompt'; +} + +function webSupported(): boolean { + return typeof window !== 'undefined' && 'Notification' in window; +} + +/** Current permission state, without prompting. */ +export async function getNotificationPermission(): Promise { + if (isNativePlatform()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const res = await LocalNotifications.checkPermissions(); + return mapNative(res.display as NativeDisplay); + } catch (err) { + console.warn('[notify] LocalNotifications unavailable', err); + return 'unsupported'; + } + } + if (!webSupported()) return 'unsupported'; + return mapWeb(Notification.permission); +} + +/** Ask the OS for permission. Must be called from a user gesture on the web. */ +export async function requestNotificationPermission(): Promise { + if (isNativePlatform()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const res = await LocalNotifications.requestPermissions(); + return mapNative(res.display as NativeDisplay); + } catch (err) { + console.warn('[notify] LocalNotifications unavailable', err); + return 'unsupported'; + } + } + if (!webSupported()) return 'unsupported'; + try { + return mapWeb(await Notification.requestPermission()); + } catch (err) { + console.warn('[notify] requestPermission failed', err); + return 'unsupported'; + } +} + +export interface OsNotificationInput { + /** Stable id — reused ids replace rather than stack on both platforms. */ + id: string; + title: string; + body?: string; + /** In-app path to open when the notification is tapped, e.g. `/messages`. */ + path?: string; +} + +/** Stable 32-bit int for the native notification id (which must be numeric). */ +function numericId(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + const n = Math.abs(hash) % 2147483647; + return n === 0 ? 1 : n; +} + +/** Show an OS notification. Returns whether it was actually delivered. */ +export async function showOsNotification(input: OsNotificationInput): Promise { + if (isNativePlatform()) { + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + await LocalNotifications.schedule({ + notifications: [ + { + id: numericId(input.id), + title: input.title, + body: input.body ?? '', + extra: { path: input.path ?? '/' }, + }, + ], + }); + return true; + } catch (err) { + console.warn('[notify] schedule failed', err); + return false; + } + } + + if (!webSupported() || Notification.permission !== 'granted') return false; + try { + const notification = new Notification(input.title, { + body: input.body, + tag: input.id, + icon: `${import.meta.env.BASE_URL}images/logo.png`, + data: { path: input.path ?? '/' }, + }); + notification.onclick = () => { + try { + window.focus(); + if (input.path) window.location.hash = `#${input.path}`; + notification.close(); + } catch { + // Ignore — the tap is best-effort. + } + }; + return true; + } catch (err) { + console.warn('[notify] web Notification failed', err); + return false; + } +} + +/** + * Subscribe to native notification taps. No-op on the web (where `onclick` is + * wired per notification instead). Returns an unsubscribe function. + */ +export async function listenForNotificationTaps( + handler: (path: string) => void, +): Promise<() => void> { + if (!isNativePlatform()) return () => {}; + try { + const { LocalNotifications } = await import('@capacitor/local-notifications'); + const listener = await LocalNotifications.addListener( + 'localNotificationActionPerformed', + (action) => { + const extra = action.notification.extra as { path?: string } | undefined; + if (extra?.path) handler(extra.path); + }, + ); + return () => { + void listener.remove(); + }; + } catch (err) { + console.warn('[notify] tap listener unavailable', err); + return () => {}; + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 89fb7be..f659b4e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -11,6 +11,7 @@ import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { GrantProCard } from '@/components/GrantProCard'; import { AdminReviewCard } from '@/components/AdminReviewCard'; +import { NotificationSettingsCard } from '@/components/NotificationSettingsCard'; import { LanguageSelector } from '@/components/LanguageToggle'; import { DesktopNavManager } from '@/components/DesktopNavManager'; import { MyDataTab } from '@/components/MyDataTab'; @@ -28,7 +29,7 @@ import { toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { NIP05_PROVIDERS } from '@/lib/nip05Providers'; import { SKATE_NIP05_DOMAIN } from '@/lib/skateNip05'; -import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff } from 'lucide-react'; +import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell } from 'lucide-react'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -44,6 +45,7 @@ type Section = | 'relays' | 'wallet' | 'stash' + | 'notifications' | 'advanced'; // ─── Back header ────────────────────────────────────────────────────────────── @@ -762,6 +764,14 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { iconBg="bg-pink-500/15" onClick={() => onNavigate('appearance')} /> + onNavigate('notifications')} + /> {/* Data & Advanced */} @@ -809,7 +819,7 @@ export default function SettingsPage() { // stale bookmark to ?section=language) falls back to the menu rather than a // blank screen with no way back. const RENDERABLE_SECTIONS: Section[] = [ - 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'advanced', + 'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'advanced', ]; const requestedSection = searchParams.get('section') as Section; const initialSection = @@ -850,6 +860,12 @@ export default function SettingsPage() { {section === 'relays' && } {section === 'appearance' && } {section === 'wallet' && } + {section === 'notifications' && ( +
+ + +
+ )} {section === 'advanced' && } {/* Main menu */}