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.
This commit is contained in:
parent
3ad214683a
commit
4df961b015
6 changed files with 137 additions and 33 deletions
|
|
@ -813,6 +813,14 @@ session with **no other memory of this project**. So:
|
||||||
`android/` is regenerated, so the `file:` dep must stay in `package.json` for
|
`android/` is regenerated, so the `file:` dep must stay in `package.json` for
|
||||||
`cap sync` to add the module; `build.gradle`/manifest may need tuning per the
|
`cap sync` to add the module; `build.gradle`/manifest may need tuning per the
|
||||||
app's Capacitor version.
|
app's Capacitor version.
|
||||||
|
**Category-aware (2026-10-01):** `/subscribe` and `/native/poll/register`
|
||||||
|
carry `categories`; the server stores them per subscription/token and only
|
||||||
|
pushes matching ones (DM gift-wrap → `dms`; offer/counter/accept/attestation/
|
||||||
|
shipment → `trades` — `social`/`crew`/`newsletter` are **in-app only**, never
|
||||||
|
pushed). `/pending` uses a **per-category activity** breakdown, so it only
|
||||||
|
moves for the token's categories. `NotificationSettingsCard` **re-sends** the
|
||||||
|
choices on toggle change (`push.resubscribe()` / `background.resubscribe()`).
|
||||||
|
⚠️ An **absent** `categories` = legacy (allow all); an **empty array** = none.
|
||||||
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
|
⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers
|
||||||
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
defer it); `fast` needs a sticky notification. Never the only path. (Approach
|
||||||
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
+ tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.)
|
||||||
|
|
|
||||||
|
|
@ -57,17 +57,43 @@ webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
|
||||||
|
|
||||||
// ─── State (JSON file) ───────────────────────────────────────────────────────
|
// ─── State (JSON file) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface StoredSubscription { endpoint: string; pubkey: string; p256dh: string; auth: string }
|
interface StoredSubscription {
|
||||||
|
endpoint: string;
|
||||||
|
pubkey: string;
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
/** Which categories this device wants. Empty/absent = all (back-compat). */
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event kind → notification category, mirroring the app's `NotificationCategory`
|
||||||
|
* (only `dms` and `trades` are pushed — social/crew/newsletter are in-app only).
|
||||||
|
*/
|
||||||
|
const CATEGORY_BY_KIND: Record<number, string> = {
|
||||||
|
1059: 'dms', // NIP-17 gift wrap
|
||||||
|
30403: 'trades', // marketplace offer / counter / accept / decline / withdraw
|
||||||
|
31871: 'trades', // trade attestation (sold / bought)
|
||||||
|
31872: 'trades', // confirm-request
|
||||||
|
34967: 'trades', // shipment status
|
||||||
|
};
|
||||||
|
|
||||||
|
function allows(categories: string[] | undefined, category: string): boolean {
|
||||||
|
// ABSENT = legacy subscriber → allow everything. An EMPTY array = the user
|
||||||
|
// explicitly wants no push categories.
|
||||||
|
if (categories === undefined) return true;
|
||||||
|
return categories.includes(category);
|
||||||
|
}
|
||||||
interface State {
|
interface State {
|
||||||
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
|
subscriptions: Record<string, StoredSubscription>; // endpoint → sub
|
||||||
/** Poll token → the pubkey it may read activity for (native background polling). */
|
/** Poll token → the pubkey + categories it may read activity for (native polling). */
|
||||||
pollTokens: Record<string, { pubkey: string; createdAt: number }>;
|
pollTokens: Record<string, { pubkey: string; categories?: string[]; createdAt: number }>;
|
||||||
/**
|
/**
|
||||||
* Per-pubkey activity cursor, bumped whenever something arrives for a pubkey,
|
* Per-pubkey activity cursor with a per-category breakdown, so a native poller
|
||||||
* so a native poller can ask "anything new since N?" WITHOUT the server ever
|
* can ask "anything new since N?" for ONLY the categories the user wants —
|
||||||
* revealing *what* (content-free — the same guarantee as web push).
|
* WITHOUT the server ever revealing *what* (content-free, like web push).
|
||||||
*/
|
*/
|
||||||
activity: Record<string, { seq: number; at: number }>;
|
activity: Record<string, { seq: number; at: number; byCategory: Record<string, number> }>;
|
||||||
challenges: Record<string, { pubkey: string; expiresAt: number }>;
|
challenges: Record<string, { pubkey: string; expiresAt: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,16 +152,23 @@ function subsFor(pubkey: string): StoredSubscription[] {
|
||||||
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
|
return Object.values(state.subscriptions).filter((s) => s.pubkey === pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bump the content-free activity cursor for a pubkey (native pollers read it). */
|
/** Bump the content-free activity cursor for a pubkey + category. */
|
||||||
function recordActivity(pubkey: string): void {
|
function recordActivity(pubkey: string, category: string): void {
|
||||||
const cur = state.activity[pubkey];
|
const cur = state.activity[pubkey] ?? { seq: 0, at: 0, byCategory: {} };
|
||||||
state.activity[pubkey] = { seq: (cur?.seq ?? 0) + 1, at: Math.floor(Date.now() / 1000) };
|
const seq = cur.seq + 1;
|
||||||
|
state.activity[pubkey] = {
|
||||||
|
seq,
|
||||||
|
at: Math.floor(Date.now() / 1000),
|
||||||
|
byCategory: { ...cur.byCategory, [category]: seq },
|
||||||
|
};
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushTo(pubkey: string, payload: Record<string, unknown>): Promise<void> {
|
async function pushTo(pubkey: string, payload: Record<string, unknown>, category: string): Promise<void> {
|
||||||
recordActivity(pubkey);
|
recordActivity(pubkey, category);
|
||||||
for (const s of subsFor(pubkey)) {
|
for (const s of subsFor(pubkey)) {
|
||||||
|
// Respect the device's category choices.
|
||||||
|
if (!allows(s.categories, category)) continue;
|
||||||
try {
|
try {
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
|
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
|
||||||
|
|
@ -234,7 +267,7 @@ function handleEvent(ev: NostrEvent): void {
|
||||||
if (recipients.length !== 1) return; // fan-out wraps aren't pushable
|
if (recipients.length !== 1) return; // fan-out wraps aren't pushable
|
||||||
if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return;
|
if (!ev.tags.some(([n, v]) => n === MARKER && v === 'push')) return;
|
||||||
console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → ${recipients[0].slice(0, 12)}…`);
|
console.log(`[push] 🔔 marked wrap ${ev.id.slice(0, 12)}… → ${recipients[0].slice(0, 12)}…`);
|
||||||
void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' });
|
void pushTo(recipients[0], { title: 'SkateSpots', body: 'New message', wrapId: ev.id, url: '/#/messages' }, 'dms');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,7 +278,7 @@ function handleEvent(ev: NostrEvent): void {
|
||||||
for (const r of recipients) {
|
for (const r of recipients) {
|
||||||
if (r === ev.pubkey) continue; // never notify the author about their own message
|
if (r === ev.pubkey) continue; // never notify the author about their own message
|
||||||
console.log(`[push] 🔔 market ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`);
|
console.log(`[push] 🔔 market ${ev.kind} ${ev.id.slice(0, 12)}… → ${r.slice(0, 12)}…`);
|
||||||
void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' });
|
void pushTo(r, { title: 'SkateSpots', body: marketBody(ev), wrapId: ev.id, url: '/#/trades' }, 'trades');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +365,8 @@ Bun.serve({
|
||||||
const body = (await req.json().catch(() => null)) as {
|
const body = (await req.json().catch(() => null)) as {
|
||||||
subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
subscription?: { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||||
proof?: NostrEvent;
|
proof?: NostrEvent;
|
||||||
|
/** The device's chosen categories (only dms/trades are pushed). */
|
||||||
|
categories?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
const sub = body?.subscription;
|
const sub = body?.subscription;
|
||||||
if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
|
if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
|
||||||
|
|
@ -345,6 +380,7 @@ Bun.serve({
|
||||||
pubkey: body.proof.pubkey.toLowerCase(),
|
pubkey: body.proof.pubkey.toLowerCase(),
|
||||||
p256dh: sub.keys.p256dh,
|
p256dh: sub.keys.p256dh,
|
||||||
auth: sub.keys.auth,
|
auth: sub.keys.auth,
|
||||||
|
categories: Array.isArray(body.categories) ? body.categories : undefined,
|
||||||
};
|
};
|
||||||
save();
|
save();
|
||||||
console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`);
|
console.log(`[push] ➕ subscribed ${body.proof.pubkey.slice(0, 12)}…`);
|
||||||
|
|
@ -368,13 +404,21 @@ Bun.serve({
|
||||||
// token; it then polls `/pending` and shows a LOCAL notification when the
|
// token; it then polls `/pending` and shows a LOCAL notification when the
|
||||||
// content-free activity cursor moves. The server never reveals what arrived.
|
// content-free activity cursor moves. The server never reveals what arrived.
|
||||||
if (req.method === 'POST' && url.pathname === '/native/poll/register') {
|
if (req.method === 'POST' && url.pathname === '/native/poll/register') {
|
||||||
const body = (await req.json().catch(() => null)) as { pubkey?: string; proof?: NostrEvent } | null;
|
const body = (await req.json().catch(() => null)) as
|
||||||
|
| { pubkey?: string; proof?: NostrEvent; categories?: string[] }
|
||||||
|
| null;
|
||||||
if (!body?.pubkey || !body?.proof) return json({ error: 'pubkey and proof required' }, 400, headers);
|
if (!body?.pubkey || !body?.proof) return json({ error: 'pubkey and proof required' }, 400, headers);
|
||||||
const check = verifyProof(body.proof, body.pubkey);
|
const check = verifyProof(body.proof, body.pubkey);
|
||||||
if (!check.ok) return json({ error: check.error }, 401, headers);
|
if (!check.ok) return json({ error: check.error }, 401, headers);
|
||||||
|
// One token per pubkey: re-registering (e.g. after a settings change)
|
||||||
|
// replaces the old one so nothing is orphaned.
|
||||||
|
for (const [t, e] of Object.entries(state.pollTokens)) {
|
||||||
|
if (e.pubkey === body.pubkey.toLowerCase()) delete state.pollTokens[t];
|
||||||
|
}
|
||||||
const token = crypto.randomUUID();
|
const token = crypto.randomUUID();
|
||||||
state.pollTokens[token] = {
|
state.pollTokens[token] = {
|
||||||
pubkey: body.pubkey.toLowerCase(),
|
pubkey: body.pubkey.toLowerCase(),
|
||||||
|
categories: Array.isArray(body.categories) ? body.categories : undefined,
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
};
|
};
|
||||||
save();
|
save();
|
||||||
|
|
@ -401,12 +445,14 @@ Bun.serve({
|
||||||
const entry = state.pollTokens[token];
|
const entry = state.pollTokens[token];
|
||||||
if (!entry) return json({ error: 'unknown token' }, 401, headers);
|
if (!entry) return json({ error: 'unknown token' }, 401, headers);
|
||||||
const since = Number(url.searchParams.get('since') ?? 0);
|
const since = Number(url.searchParams.get('since') ?? 0);
|
||||||
const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0 };
|
const activity = state.activity[entry.pubkey] ?? { seq: 0, at: 0, byCategory: {} };
|
||||||
return json(
|
// Only the categories THIS token asked for can move `changed`.
|
||||||
{ seq: activity.seq, at: activity.at, changed: activity.seq > (Number.isFinite(since) ? since : 0) },
|
const allowed = Array.isArray(entry.categories)
|
||||||
200,
|
? entry.categories
|
||||||
headers,
|
: Object.keys(activity.byCategory);
|
||||||
);
|
const cutoff = Number.isFinite(since) ? since : 0;
|
||||||
|
const changed = allowed.some((c) => (activity.byCategory[c] ?? 0) > cutoff);
|
||||||
|
return json({ seq: activity.seq, at: activity.at, changed }, 200, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ error: 'not found' }, 404, headers);
|
return json({ error: 'not found' }, 404, headers);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Bell, Loader2, Radio, 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';
|
||||||
|
|
@ -30,6 +30,20 @@ export function NotificationSettingsCard() {
|
||||||
const background = useBackgroundDelivery();
|
const background = useBackgroundDelivery();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// When the category choices change, re-send them to whatever channel is on, so
|
||||||
|
// a closed-app push respects the toggles (the server filters by category).
|
||||||
|
const categoriesKey = push.categories.join(',');
|
||||||
|
const firstCategoriesRun = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstCategoriesRun.current) {
|
||||||
|
firstCategoriesRun.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (push.state === 'on') void push.resubscribe();
|
||||||
|
if (background.mode !== 'off') void background.resubscribe();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [categoriesKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void getNotificationPermission().then((result) => {
|
void getNotificationPermission().then((result) => {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
* Two modes (chosen in Settings): `standard` = WorkManager ~15 min (best-effort),
|
* Two modes (chosen in Settings): `standard` = WorkManager ~15 min (best-effort),
|
||||||
* `fast` = a foreground service ~5 min (sticky notification).
|
* `fast` = a foreground service ~5 min (sticky notification).
|
||||||
*/
|
*/
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { registerPlugin } from '@capacitor/core';
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
||||||
|
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
||||||
import { isNativePlatform, getPlatform } from '@/lib/platform';
|
import { isNativePlatform, getPlatform } from '@/lib/platform';
|
||||||
import { pushServiceBase, requestPushChallenge } from '@/lib/pushService';
|
import { pushServiceBase, requestPushChallenge } from '@/lib/pushService';
|
||||||
|
|
||||||
|
|
@ -32,6 +34,11 @@ export function useBackgroundDelivery() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const supported = isNativePlatform() && getPlatform() === 'android';
|
const supported = isNativePlatform() && getPlatform() === 'android';
|
||||||
const [mode, setMode] = useState<DeliveryMode>('off');
|
const [mode, setMode] = useState<DeliveryMode>('off');
|
||||||
|
const [prefs] = useNotificationPrefs();
|
||||||
|
const categories = useMemo(
|
||||||
|
() => NOTIFICATION_CATEGORIES.map((c) => c.key).filter((k) => k !== 'reminders' && prefs[k]),
|
||||||
|
[prefs],
|
||||||
|
);
|
||||||
|
|
||||||
const enable = useCallback(
|
const enable = useCallback(
|
||||||
async (nextMode: Exclude<DeliveryMode, 'off'>, intervalMinutes = 5) => {
|
async (nextMode: Exclude<DeliveryMode, 'off'>, intervalMinutes = 5) => {
|
||||||
|
|
@ -49,7 +56,7 @@ export function useBackgroundDelivery() {
|
||||||
const res = await fetch(`${pushServiceBase()}/native/poll/register`, {
|
const res = await fetch(`${pushServiceBase()}/native/poll/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ pubkey: user.pubkey, proof }),
|
body: JSON.stringify({ pubkey: user.pubkey, proof, categories }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Could not register for background delivery.');
|
if (!res.ok) throw new Error('Could not register for background delivery.');
|
||||||
const { token, seq } = (await res.json()) as { token: string; seq: number };
|
const { token, seq } = (await res.json()) as { token: string; seq: number };
|
||||||
|
|
@ -58,9 +65,15 @@ export function useBackgroundDelivery() {
|
||||||
await SkateSpotsBackground.configure({ mode: nextMode, intervalMinutes });
|
await SkateSpotsBackground.configure({ mode: nextMode, intervalMinutes });
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
},
|
},
|
||||||
[supported, user],
|
[supported, user, categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Re-register with the current category choices (after a toggle change). */
|
||||||
|
const resubscribe = useCallback(async () => {
|
||||||
|
if (mode === 'off') return;
|
||||||
|
await enable(mode, mode === 'fast' ? 5 : undefined);
|
||||||
|
}, [mode, enable]);
|
||||||
|
|
||||||
const disable = useCallback(async () => {
|
const disable = useCallback(async () => {
|
||||||
if (!supported) return;
|
if (!supported) return;
|
||||||
await SkateSpotsBackground.configure({ mode: 'off' });
|
await SkateSpotsBackground.configure({ mode: 'off' });
|
||||||
|
|
@ -68,5 +81,5 @@ export function useBackgroundDelivery() {
|
||||||
setMode('off');
|
setMode('off');
|
||||||
}, [supported]);
|
}, [supported]);
|
||||||
|
|
||||||
return { supported, mode, enable, disable };
|
return { supported, mode, enable, disable, resubscribe, categories };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
* it to the service. Disabling reverses it. Nothing is sent anywhere except the
|
* it to the service. Disabling reverses it. Nothing is sent anywhere except the
|
||||||
* pubkey + this device's push endpoint.
|
* pubkey + this device's push endpoint.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useNotificationPrefs } from '@/hooks/useNotificationPrefs';
|
||||||
|
import { NOTIFICATION_CATEGORIES } from '@/lib/notificationPrefs';
|
||||||
import {
|
import {
|
||||||
fetchVapidPublicKey,
|
fetchVapidPublicKey,
|
||||||
requestPushChallenge,
|
requestPushChallenge,
|
||||||
|
|
@ -41,6 +43,12 @@ export function usePushNotifications() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const [state, setState] = useState<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
|
const [state, setState] = useState<PushState>(() => (pushSupported() ? 'off' : 'unsupported'));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [prefs] = useNotificationPrefs();
|
||||||
|
// Only the categories the device wants (the server pushes dms/trades only).
|
||||||
|
const categories = useMemo(
|
||||||
|
() => NOTIFICATION_CATEGORIES.map((c) => c.key).filter((k) => k !== 'reminders' && prefs[k]),
|
||||||
|
[prefs],
|
||||||
|
);
|
||||||
|
|
||||||
// Reflect an existing subscription on mount / account switch.
|
// Reflect an existing subscription on mount / account switch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -104,13 +112,24 @@ export function usePushNotifications() {
|
||||||
const challenge = await requestPushChallenge(user.pubkey);
|
const challenge = await requestPushChallenge(user.pubkey);
|
||||||
const proof = await signProof(challenge);
|
const proof = await signProof(challenge);
|
||||||
|
|
||||||
await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof);
|
await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof, categories);
|
||||||
setState('on');
|
setState('on');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Could not enable background notifications.');
|
setError(err instanceof Error ? err.message : 'Could not enable background notifications.');
|
||||||
setState('error');
|
setState('error');
|
||||||
}
|
}
|
||||||
}, [user, signProof]);
|
}, [user, signProof, categories]);
|
||||||
|
|
||||||
|
/** Re-send the current category choices after a toggle changes. */
|
||||||
|
const resubscribe = useCallback(async () => {
|
||||||
|
if (!pushSupported() || !user?.pubkey) return;
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration();
|
||||||
|
const sub = await reg?.pushManager.getSubscription();
|
||||||
|
if (!sub) return;
|
||||||
|
const challenge = await requestPushChallenge(user.pubkey);
|
||||||
|
const proof = await signProof(challenge);
|
||||||
|
await subscribeToPush(sub.toJSON() as PushSubscriptionJSON, proof, categories);
|
||||||
|
}, [user, signProof, categories]);
|
||||||
|
|
||||||
const disable = useCallback(async () => {
|
const disable = useCallback(async () => {
|
||||||
if (!pushSupported() || !user?.pubkey) {
|
if (!pushSupported() || !user?.pubkey) {
|
||||||
|
|
@ -137,5 +156,5 @@ export function usePushNotifications() {
|
||||||
}
|
}
|
||||||
}, [user, signProof]);
|
}, [user, signProof]);
|
||||||
|
|
||||||
return { state, error, supported: pushSupported(), enable, disable };
|
return { state, error, supported: pushSupported(), enable, disable, resubscribe, categories };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,12 @@ export async function requestPushChallenge(pubkey: string): Promise<string> {
|
||||||
return data.challenge;
|
return data.challenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subscribeToPush(subscription: PushSubscriptionJSON, proof: unknown): Promise<void> {
|
export async function subscribeToPush(
|
||||||
await post('/subscribe', { subscription, proof });
|
subscription: PushSubscriptionJSON,
|
||||||
|
proof: unknown,
|
||||||
|
categories?: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
await post('/subscribe', { subscription, proof, categories });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
|
export async function unsubscribeFromPush(endpoint: string, proof: unknown): Promise<void> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue