feat(notify): opt-in on-device OS notifications (Phase 1)

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).
This commit is contained in:
SkateSpots 2026-09-29 12:59:13 +07:00
parent 120c76b052
commit 63dc4bee7d
13 changed files with 860 additions and 3 deletions

View file

@ -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

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 */}
<NewsletterOverlay />
{/* Mirrors in-app activity to OS notifications — inert until opted in. */}
<OsNotificationBridge />
</div>
);
}

View file

@ -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<OsPermission | 'loading'>('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 (
<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>
{/* 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>
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.
</span>
</p>
</div>
</div>
);
}

View file

@ -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<Record<Source, boolean>>;
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/<pubkey>/<identifier>` 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<void> {
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 <NotificationEngine prefs={prefs} />;
}
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<SeenState | null>(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;
}

View file

@ -221,6 +221,7 @@ export function useActivityNotifications() {
return {
notifications: query.data ?? [],
isLoading: query.isLoading,
isFetched: query.isFetched,
};
}

View file

@ -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();

View file

@ -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<DmEnvelope[]> => {
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<string, number>();
for (const event of batches.flat()) unique.set(event.id, event.created_at);
return [...unique].map(([id, createdAt]) => ({ id, createdAt }));
},
});
}

View file

@ -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<NotificationPrefs>(
NOTIFICATION_PREFS_KEY,
DEFAULT_NOTIFICATION_PREFS,
);
const prefs = useMemo<NotificationPrefs>(
() => ({ ...DEFAULT_NOTIFICATION_PREFS, ...(stored ?? {}) }),
[stored],
);
return [prefs, setPrefs] as const;
}

View file

@ -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)' },
];

162
src/lib/osNotify.ts Normal file
View file

@ -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<OsPermission> {
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<OsPermission> {
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<boolean> {
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 () => {};
}
}

View file

@ -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')}
/>
<MenuRow
icon={Bell}
label="Notifications"
sublabel="Alerts for DMs, crews and zaps"
iconColor="text-yellow-400"
iconBg="bg-yellow-500/15"
onClick={() => onNavigate('notifications')}
/>
</MenuGroup>
{/* 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' && <RelaysScreen onBack={handleBack} />}
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
{section === 'notifications' && (
<div>
<BackHeader title="Notifications" onBack={handleBack} />
<NotificationSettingsCard />
</div>
)}
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
{/* Main menu */}