SkateSpots/src/hooks/useDmEnvelopes.ts
SkateSpots 63dc4bee7d feat(notify): opt-in on-device OS notifications (Phase 1)
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.

- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
  uses @capacitor/local-notifications (lazily imported, so the web bundle never
  pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
  opted in. Per-source silent priming + a seen-id set means history is never
  replayed as a flood, and it stays quiet while the app is visible (the bell
  already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
  no decryption) — so Amber is never prompted just to be told you have a
  message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
  default off, per-category toggles. Settings -> Notifications UI.

Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
2026-09-29 12:59:13 +07:00

60 lines
2.1 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useResolvedDmRelays } from '@/hooks/useDirectMessages';
/**
* Lightweight DM "envelope" watcher — the id and timestamp of every NIP-17 gift
* wrap addressed to the user, **without decrypting anything**.
*
* This is what makes DM notifications possible without ever prompting Amber:
* the seal/rumor are encrypted, so we only see that *something* arrived. No
* sender, no content, no signer round-trip. The preview is intentionally generic
* ("New direct message") — the honest ceiling when we refuse to decrypt in the
* background.
*
* NIP-17 randomises the outer wrap timestamp (up to 2 days in the past), so we
* cannot reliably tell "new" from `created_at`. Callers should diff on the
* stable `id` instead.
*/
const GIFT_WRAP_KIND = 1059;
export interface DmEnvelope {
id: string;
createdAt: number;
}
export function useDmEnvelopes(enabled: boolean) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { relays, key } = useResolvedDmRelays();
return useQuery({
queryKey: ['dm-envelopes', user?.pubkey, key],
enabled: enabled && !!user?.pubkey && relays.length > 0,
staleTime: 60_000,
refetchInterval: 90_000,
queryFn: async (c): Promise<DmEnvelope[]> => {
const pubkey = user!.pubkey;
const batches = await Promise.all(
relays.map(async (relayUrl) => {
try {
const relay = nostr.relay(relayUrl);
return await relay.query(
[{ kinds: [GIFT_WRAP_KIND], '#p': [pubkey], limit: 60 }],
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(8000)]) },
);
} catch {
// One relay failing must not stop the others.
return [];
}
}),
);
const unique = new Map<string, number>();
for (const event of batches.flat()) unique.set(event.id, event.created_at);
return [...unique].map(([id, createdAt]) => ({ id, createdAt }));
},
});
}