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 */}