feat(push): client half - subscribe flow, service worker, DM marker
- public/push-sw.js: shows the content-free push; tap focuses/opens the app. - src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe. - NotificationSettingsCard: a 'Background notifications' toggle (opt-in, per-device) + honest metadata copy; corrected the now-stale 'we run no push server' note. - useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never the self copy) and adds the community relay as the notify relay so the push service sees it. - /privacy: discloses the push server sees metadata only.
This commit is contained in:
parent
807037ee9b
commit
38317ee2a5
7 changed files with 330 additions and 11 deletions
15
AGENTS.md
15
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
|
||||
|
|
|
|||
40
public/push-sw.js
Normal file
40
public/push-sw.js
Normal file
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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<OsPermission | 'loading'>('loading');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const push = usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -120,6 +122,57 @@ export function NotificationSettingsCard() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Background push (closed-app) */}
|
||||
<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">
|
||||
<Radio className="h-4 w-4 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-white">Background notifications</p>
|
||||
<p className="mt-0.5 text-xs leading-tight text-muted-foreground">
|
||||
Get pinged for messages and trades even when SkateSpots is closed.
|
||||
</p>
|
||||
</div>
|
||||
{push.state === 'working' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={push.state === 'on'}
|
||||
disabled={push.state === 'unsupported'}
|
||||
onCheckedChange={(value) => {
|
||||
if (value) void push.enable();
|
||||
else void push.disable();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{push.state === '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 browser doesn't support background push. On mobile, add SkateSpots to your home screen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{push.state === 'error' && push.error && (
|
||||
<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" />
|
||||
{push.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
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 <em>that</em> your key has something — never who sent it or
|
||||
what it says, and it can't decrypt your messages. Opt in per device.
|
||||
</span>
|
||||
</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">
|
||||
|
|
@ -159,10 +212,10 @@ export function NotificationSettingsCard() {
|
|||
<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.
|
||||
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{' '}
|
||||
<strong className="text-white">Background notifications</strong> off, a fully closed
|
||||
app can't be reached, so alerts arrive next time you open SkateSpots.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
141
src/hooks/usePushNotifications.ts
Normal file
141
src/hooks/usePushNotifications.ts
Normal file
|
|
@ -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<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
58
src/lib/pushService.ts
Normal file
58
src/lib/pushService.ts
Normal file
|
|
@ -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<string, string | undefined>).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<T>(path: string, body: unknown): Promise<T> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const data = await post<{ challenge: string }>('/auth/challenge', { pubkey });
|
||||
return data.challenge;
|
||||
}
|
||||
|
||||
export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise<void> {
|
||||
await post('/subscribe', { subscription, proof });
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
|
||||
await post('/unsubscribe', { endpoint, proof });
|
||||
}
|
||||
|
|
@ -149,6 +149,7 @@ export default function PrivacyPage() {
|
|||
<ul className="list-disc pl-5 space-y-1 text-gray-400">
|
||||
<li><strong className="text-white">Map tiles</strong> (OpenStreetMap) — see your IP and which area of the map you look at.</li>
|
||||
<li><strong className="text-white">Spot/shop geocoding</strong> — when you <em>create or edit</em> a spot or shop, its coordinates go to OpenStreetMap <strong className="text-white">Nominatim</strong> to fill in the city/country tag. When you open <strong className="text-white">Community Stats</strong>, the coordinates of pins that have no location tag go to <strong className="text-white">BigDataCloud</strong> so they can be grouped by region (cached on your device — at most once per pin). Both see your IP. These are the <em>pin you placed / public pin coordinates</em>, never your device's live GPS.</li>
|
||||
<li><strong className="text-white">Background notifications</strong> (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.</li>
|
||||
<li><strong className="text-white">Bitcoin places</strong> (BTCMap, when you switch that layer on) — see your IP and the area you're viewing.</li>
|
||||
<li><strong className="text-white">Nostr relays</strong> — see your IP when the app reads or publishes.</li>
|
||||
<li><strong className="text-white">Cashu mints</strong> — see your IP whenever your wallet sends, receives or melts.</li>
|
||||
|
|
|
|||
Loading…
Reference in a new issue