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).
60 lines
2.1 KiB
TypeScript
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 }));
|
|
},
|
|
});
|
|
}
|