SkateSpots/src/lib/pushService.ts
SkateSpots 4df961b015 feat(push): category-aware notifications (trades-only actually means trades-only)
- /subscribe + /native/poll/register carry the enabled categories; the server
  stores them per subscription/token and only pushes matching ones (DM wrap ->
  dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
  (all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
  token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
  and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
2026-10-01 20:01:07 +07:00

62 lines
2.3 KiB
TypeScript

/**
* 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,
categories?: string[],
): Promise<void> {
await post('/subscribe', { subscription, proof, categories });
}
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
await post('/unsubscribe', { endpoint, proof });
}