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
|
marked wrap must land on a relay the service watches — designate the
|
||||||
community relay as the **notify relay** and publish push-marked recipient
|
community relay as the **notify relay** and publish push-marked recipient
|
||||||
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
|
copies there. ⚠️ Privacy: the server sees **metadata only** (pubkey ↔ push
|
||||||
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **STILL
|
endpoint, timing); it **never decrypts**; disclosed on `/privacy`. **Client
|
||||||
TODO (client half of Phase 1):** the app service worker, the
|
half done:** `public/push-sw.js` (service worker), `src/lib/pushService.ts` +
|
||||||
subscribe/unsubscribe hook + Settings toggle, and adding the marker to
|
`src/hooks/usePushNotifications.ts` (register + subscribe with a NIP-98
|
||||||
`useSendDirectMessage`'s recipient wraps. Android FCM is Phase 2.
|
challenge proof), a **Background notifications** toggle in
|
||||||
(Approach + tradeoffs came from the Numo/Linky/BTCPay review this session.)
|
`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
|
- **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS
|
||||||
delivery coordinator** exposed as a **ContextVM server** over Nostr relays
|
delivery coordinator** exposed as a **ContextVM server** over Nostr relays
|
||||||
(E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is
|
(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 { 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 { Switch } from '@/components/ui/switch';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
||||||
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
||||||
import {
|
import {
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
|
|
@ -23,6 +24,7 @@ export function NotificationSettingsCard() {
|
||||||
const [prefs, setPrefs] = useNotificationPrefs();
|
const [prefs, setPrefs] = useNotificationPrefs();
|
||||||
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
|
const [permission, setPermission] = useState<OsPermission | 'loading'>('loading');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const push = usePushNotifications();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -120,6 +122,57 @@ export function NotificationSettingsCard() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Per-category toggles */}
|
||||||
<div className="overflow-hidden rounded-2xl border border-white/8 bg-white/2">
|
<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">
|
<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">
|
<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]" />
|
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-[#ccff00]" />
|
||||||
<span>
|
<span>
|
||||||
Notifications are built on your device from data the app already has. Nothing
|
Alerts are built on your device from data the app already has, and direct-message
|
||||||
extra is sent anywhere, and direct-message alerts never decrypt your messages —
|
alerts never decrypt your messages — so Amber is never asked to open one. With{' '}
|
||||||
so Amber is never asked to open one. While the app is fully closed Android and
|
<strong className="text-white">Background notifications</strong> off, a fully closed
|
||||||
iOS can't reach it, so those alerts arrive next time you open SkateSpots.
|
app can't be reached, so alerts arrive next time you open SkateSpots.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useCurrentUser } from './useCurrentUser';
|
||||||
import { useAppContext } from './useAppContext';
|
import { useAppContext } from './useAppContext';
|
||||||
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
import type { RelayMetadata } from '@/contexts/AppContext';
|
import type { RelayMetadata } from '@/contexts/AppContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -152,6 +153,13 @@ export function getGroupId(participants: string[]): string {
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const GIFT_WRAP_KIND = 1059;
|
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 SEAL_KIND = 13;
|
||||||
const DM_KIND = 14;
|
const DM_KIND = 14;
|
||||||
const TWO_DAYS = 2 * 24 * 60 * 60;
|
const TWO_DAYS = 2 * 24 * 60 * 60;
|
||||||
|
|
@ -257,13 +265,18 @@ export function useSendDirectMessage() {
|
||||||
);
|
);
|
||||||
const encryptedSeal = nip44.v2.encrypt(sealJson, conversationKey);
|
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.
|
// Create gift wrap event and sign with ephemeral key.
|
||||||
// finalizeEvent takes an EventTemplate (no pubkey — it derives it from
|
// finalizeEvent takes an EventTemplate (no pubkey — it derives it from
|
||||||
// the secret key) and returns a fully-signed event.
|
// the secret key) and returns a fully-signed event.
|
||||||
return finalizeEvent({
|
return finalizeEvent({
|
||||||
created_at: randomPastTimestamp(),
|
created_at: randomPastTimestamp(),
|
||||||
kind: GIFT_WRAP_KIND,
|
kind: GIFT_WRAP_KIND,
|
||||||
tags: [['p', targetPubkey]],
|
tags: wrapTags,
|
||||||
content: encryptedSeal,
|
content: encryptedSeal,
|
||||||
}, ephemeralSecretKey) as NostrEvent;
|
}, ephemeralSecretKey) as NostrEvent;
|
||||||
};
|
};
|
||||||
|
|
@ -322,6 +335,14 @@ export function useSendDirectMessage() {
|
||||||
const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
|
const blocked = new Set((config.relayMetadata.blockedRelays ?? []).map((u) => u.toLowerCase()));
|
||||||
let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
|
let publishRelays = [...deliverRelays].filter((u) => !blocked.has(u.toLowerCase()));
|
||||||
if (publishRelays.length === 0) publishRelays = write;
|
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
|
// 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.
|
// 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">
|
<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">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">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">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">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>
|
<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