diff --git a/AGENTS.md b/AGENTS.md index 1ecfee6..ca2a69a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -691,11 +691,16 @@ session with **no other memory of this project**. So: marked wrap must land on a relay the service watches — designate the community relay as the **notify relay** and publish push-marked recipient copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push - endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **STILL - TODO (client half of Phase 1):** the app service worker, the - subscribe/unsubscribe hook + Settings toggle, and adding the marker to - `useSendDirectMessage`'s recipient wraps. Android FCM is Phase 2. - (Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.) + endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **Client + half done:** `public/push-sw.js` (service worker), `src/lib/pushService.ts` + + `src/hooks/usePushNotifications.ts` (register + subscribe with a NIP-98 + challenge proof), a **Background notifications** toggle in + `NotificationSettingsCard`, and `useSendDirectMessage` now marks the + **recipient** wrap `["skatespots","push"]` **and adds the community relay as + the notify relay**. **Remaining:** deploy (`npm run deploy:push` + Caddy) and + Android FCM (Phase 2). ⚠️ Web push works in browsers; the Capacitor WebView + needs FCM for the APK. (Approach + tradeoffs came from the + Numo/Linky/BTCPay review this session.) - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS delivery coordinator** exposed as a **ContextVM server** over Nostr relays (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..d872e6b --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,40 @@ +/* + * SkateSpots push service worker. + * + * Receives a Web Push payload from `push-service/` — which is CONTENT-FREE + * ({ title, body, wrapId, url }) because the server never decrypts anything — + * and shows an OS notification. Tapping it focuses/opens the app, where the real + * message is fetched by wrapId and decrypted locally. + * + * Deliberately tiny: no caching, no offline logic. + */ +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + /* non-JSON payload — fall back to defaults */ + } + const title = data.title || 'SkateSpots'; + const body = data.body || 'You have a new notification'; + event.waitUntil( + self.registration.showNotification(title, { + body, + tag: data.wrapId || undefined, + data: { url: data.url || '/' }, + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = (event.notification.data && event.notification.data.url) || '/'; + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => { + for (const client of list) { + if ('focus' in client) return client.focus(); + } + return self.clients.openWindow(target); + }), + ); +}); diff --git a/src/components/NotificationSettingsCard.tsx b/src/components/NotificationSettingsCard.tsx index 62c4e4d..902c6f5 100644 --- a/src/components/NotificationSettingsCard.tsx +++ b/src/components/NotificationSettingsCard.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { Bell, Loader2, ShieldCheck, TriangleAlert } from 'lucide-react'; +import { Bell, Loader2, Radio, ShieldCheck, TriangleAlert } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { useNotificationPrefs } from '@/hooks/useNotificationPrefs'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs'; import { getNotificationPermission, @@ -23,6 +24,7 @@ export function NotificationSettingsCard() { const [prefs, setPrefs] = useNotificationPrefs(); const [permission, setPermission] = useState('loading'); const [busy, setBusy] = useState(false); + const push = usePushNotifications(); useEffect(() => { let cancelled = false; @@ -120,6 +122,57 @@ export function NotificationSettingsCard() { )} + {/* Background push (closed-app) */} +
+
+
+ +
+
+

Background notifications

+

+ Get pinged for messages and trades even when SkateSpots is closed. +

+
+ {push.state === 'working' ? ( + + ) : ( + { + if (value) void push.enable(); + else void push.disable(); + }} + /> + )} +
+ + {push.state === 'unsupported' && ( +

+ + This browser doesn't support background push. On mobile, add SkateSpots to your home screen. +

+ )} + + {push.state === 'error' && push.error && ( +

+ + {push.error} +

+ )} + +

+ + + To reach you while closed, a SkateSpots notification server gets a ping from your + relay when something arrives for your key and passes it to your phone's push + service. It sees only that your key has something — never who sent it or + what it says, and it can't decrypt your messages. Opt in per device. + +

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

@@ -159,10 +212,10 @@ export function NotificationSettingsCard() {

- 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. + Alerts are built on your device from data the app already has, and direct-message + alerts never decrypt your messages — so Amber is never asked to open one. With{' '} + Background notifications off, a fully closed + app can't be reached, so alerts arrive next time you open SkateSpots.

diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 7a0fd08..86e984f 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -9,6 +9,7 @@ import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { useRelays } from '@/lib/relays'; +import { SKATE_RELAY } from '@/lib/types'; import type { RelayMetadata } from '@/contexts/AppContext'; /** @@ -152,6 +153,13 @@ export function getGroupId(participants: string[]): string { // Constants const GIFT_WRAP_KIND = 1059; + +/** + * Plaintext marker on the RECIPIENT copy of a gift wrap. The push service watches + * for it (see `push-service/`); it leaks only "this wrap wants a push", nothing + * about the content. Never added to the self copy. + */ +const PUSH_MARKER_TAG = 'skatespots'; const SEAL_KIND = 13; const DM_KIND = 14; const TWO_DAYS = 2 * 24 * 60 * 60; @@ -257,13 +265,18 @@ export function useSendDirectMessage() { ); const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey); + // Mark the RECIPIENT copy (never the self copy) as push-worthy, so the + // push service can wake their device while the app is closed. + const wrapTags: string[][] = [['p', targetPubkey]]; + if (targetPubkey !== user.pubkey) wrapTags.push([PUSH_MARKER_TAG, 'push']); + // Create gift wrap event and sign with ephemeral key. // finalizeEvent takes an EventTemplate (no pubkey — it derives it from // the secret key) and returns a fully-signed event. return finalizeEvent({ created_at: randomPastTimestamp(), kind: GIFT_WRAP_KIND, - tags: [['p', targetPubkey]], + tags: wrapTags, content: encryptedSeal, }, ephemeralSecretKey) as NostrEvent; }; @@ -322,6 +335,14 @@ export function useSendDirectMessage() { const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase())); let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase())); if (publishRelays.length === 0) publishRelays = write; + // The push service watches the community relay for push-marked wraps, so the + // recipient copy must land there for a closed-app notification to fire. + if ( + !blocked.has(SKATE_RELAY.toLowerCase()) && + !publishRelays.some((u) => u.toLowerCase() === SKATE_RELAY.toLowerCase()) + ) { + publishRelays = [...publishRelays, SKATE_RELAY]; + } // 5. Publish in PARALLEL, each relay with its own timeout. People's lists // commonly contain a dead relay or one that demands NIP-42 auth (e.g. diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts new file mode 100644 index 0000000..dab4a18 --- /dev/null +++ b/src/hooks/usePushNotifications.ts @@ -0,0 +1,141 @@ +/** + * usePushNotifications — opt in/out of the SkateSpots push service (see + * `push-service/`) for this device. + * + * Enabling: register the push service worker, ask for permission, subscribe with + * the service's VAPID key, sign a NIP-98 (kind 27235) challenge proof, and POST + * it to the service. Disabling reverses it. Nothing is sent anywhere except the + * pubkey + this device's push endpoint. + */ +import { useCallback, useEffect, useState } from 'react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { + fetchVapidPublicKey, + requestPushChallenge, + subscribeToPush, + unsubscribeFromPush, + type PushSubscriptionJSON, +} from '@/lib/pushService'; + +export type PushState = 'unsupported' | 'off' | 'on' | 'working' | 'error'; + +function pushSupported(): boolean { + return ( + typeof navigator !== 'undefined' && + 'serviceWorker' in navigator && + typeof window !== 'undefined' && + 'PushManager' in window + ); +} + +function urlBase64ToUint8Array(base64: string): Uint8Array { + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(normalized); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); + return out; +} + +export function usePushNotifications() { + const { user } = useCurrentUser(); + const [state, setState] = useState(() => (pushSupported() ? 'off' : 'unsupported')); + const [error, setError] = useState(null); + + // Reflect an existing subscription on mount / account switch. + useEffect(() => { + if (!pushSupported()) { + setState('unsupported'); + return; + } + let cancelled = false; + void (async () => { + try { + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + if (!cancelled) setState(sub ? 'on' : 'off'); + } catch { + if (!cancelled) setState('off'); + } + })(); + return () => { + cancelled = true; + }; + }, [user?.pubkey]); + + const signProof = useCallback( + async (challenge: string) => { + if (!user?.signer) throw new Error('Log in first.'); + return user.signer.signEvent({ + kind: 27235, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [['challenge', challenge]], + }); + }, + [user], + ); + + const enable = useCallback(async () => { + if (!pushSupported()) { + setState('unsupported'); + return; + } + if (!user?.pubkey || !user.signer) { + setError('Log in to enable background notifications.'); + setState('error'); + return; + } + setState('working'); + setError(null); + try { + const permission = await Notification.requestPermission(); + if (permission !== 'granted') throw new Error('Notification permission was not granted.'); + + const reg = await navigator.serviceWorker.register('/push-sw.js'); + await navigator.serviceWorker.ready; + + const vapid = await fetchVapidPublicKey(); + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapid), + }); + + const challenge = await requestPushChallenge(user.pubkey); + const proof = await signProof(challenge); + + await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof); + setState('on'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not enable background notifications.'); + setState('error'); + } + }, [user, signProof]); + + const disable = useCallback(async () => { + if (!pushSupported() || !user?.pubkey) { + setState('off'); + return; + } + setState('working'); + setError(null); + try { + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + if (sub) { + const challenge = await requestPushChallenge(user.pubkey); + const proof = await signProof(challenge); + await unsubscribeFromPush(sub.endpoint, proof).catch(() => { + /* best effort — the local unsubscribe below still runs */ + }); + await sub.unsubscribe(); + } + setState('off'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not disable background notifications.'); + setState('error'); + } + }, [user, signProof]); + + return { state, error, supported: pushSupported(), enable, disable }; +} diff --git a/src/lib/pushService.ts b/src/lib/pushService.ts new file mode 100644 index 0000000..b0bba17 --- /dev/null +++ b/src/lib/pushService.ts @@ -0,0 +1,58 @@ +/** + * Client for the SkateSpots push service (`push-service/`). + * + * The service watches relays for push-marked gift wraps and sends a CONTENT-FREE + * Web Push. It never decrypts; the app fetches + decrypts the named wrap itself. + * + * Base URL mirrors `sasquatchAiBase()`: production (and native builds) use the + * deployed host; dev points at the local service. `VITE_PUSH_SERVICE_BASE` + * overrides both. + */ +import { isNativePlatform } from '@/lib/platform'; + +const PRODUCTION_PUSH_BASE = 'https://push.skate-spots.org'; + +export function pushServiceBase(): string { + const override = (import.meta.env as Record).VITE_PUSH_SERVICE_BASE; + if (override) return override.replace(/\/+$/, ''); + if (import.meta.env.PROD || isNativePlatform()) return PRODUCTION_PUSH_BASE; + return 'http://localhost:8788'; +} + +/** Standard `PushSubscription.toJSON()` shape the service expects. */ +export interface PushSubscriptionJSON { + endpoint: string; + keys: { p256dh: string; auth: string }; +} + +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${pushServiceBase()}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as T & { error?: string }; + if (!res.ok) throw new Error(data.error || `Push service error (${res.status})`); + return data; +} + +export async function fetchVapidPublicKey(): Promise { + const res = await fetch(`${pushServiceBase()}/vapid-public-key`); + if (!res.ok) throw new Error('Could not reach the notification service.'); + const data = (await res.json()) as { key?: string }; + if (!data.key) throw new Error('Notification service returned no key.'); + return data.key; +} + +export async function requestPushChallenge(pubkey: string): Promise { + const data = await post<{ challenge: string }>('/auth/challenge', { pubkey }); + return data.challenge; +} + +export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise { + await post('/subscribe', { subscription, proof }); +} + +export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise { + await post('/unsubscribe', { endpoint, proof }); +} diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx index 6f7699d..730fe26 100644 --- a/src/pages/PrivacyPage.tsx +++ b/src/pages/PrivacyPage.tsx @@ -149,6 +149,7 @@ export default function PrivacyPage() {
  • Map tiles (OpenStreetMap) — see your IP and which area of the map you look at.
  • Spot/shop geocoding — when you create or edit a spot or shop, its coordinates go to OpenStreetMap Nominatim to fill in the city/country tag. When you open Community Stats, the coordinates of pins that have no location tag go to BigDataCloud so they can be grouped by region (cached on your device — at most once per pin). Both see your IP. These are the pin you placed / public pin coordinates, never your device's live GPS.
  • +
  • Background notifications (opt-in) — if you enable them, a SkateSpots push server sees that your key received something and when (never who sent it or what it says — messages stay encrypted), and your OS push service (Google/Mozilla/Apple) delivers the alert.
  • Bitcoin places (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.
  • Nostr relays — see your IP when the app reads or publishes.
  • Cashu mints — see your IP whenever your wallet sends, receives or melts.