Third and final surface with the encrypted-replaceable-list clobber pattern (after Stash and Contacts). useDismissCrewNotification's cross-device sync path built the published set from cache-or-local-mirror, which are both empty on a fresh device before the useCrewDismissals read query resolves. A non-interactive user dismissing a notification in that window would publish a set with only the new id, overwriting — and wiping — the previously-synced dismissals from other devices. Fix: before encrypting/publishing, re-fetch + decrypt the remote dismissal set and union it with the pending id (and keep the local mirror + cache aligned). On read failure it still publishes the local union — dismissals are additive, so we can only ever under-merge, never silently wipe. Lower stakes than Stash/Contacts (worst case: a handled invite re-appears in the bell on another device, not real data loss), but it keeps the fetch-before-write discipline consistent across every encrypted list in the app. Amber path is unchanged — still local-only, zero extra prompts. Bumps to 1.2.25 (Android versionCode 10225 auto-derived). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
236 lines
9.5 KiB
TypeScript
236 lines
9.5 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useNostrLogin } from '@nostrify/react/login';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
|
import { useCallback } from 'react';
|
|
|
|
/**
|
|
* Cross-device "dismissed crew notifications" set.
|
|
*
|
|
* A crew invite / join-request lives on relays as a public event the recipient
|
|
* *doesn't* own, so they can't delete it — they can only stop it nagging them.
|
|
* Previously that was a per-device localStorage set, which meant dismissing on
|
|
* your phone left it un-dismissed on your laptop.
|
|
*
|
|
* This stores the set as a single private, addressable NIP-78 event (kind
|
|
* 30078) whose content is a NIP-44 payload encrypted to the user's own key —
|
|
* so it syncs across every device the user logs in from, and never leaks which
|
|
* invites/requests they ignored. One list covers both invites and join-requests
|
|
* (both keyed by event id).
|
|
*
|
|
* Graceful degradation: the resolved set is always the UNION of the synced
|
|
* remote list and a local mirror, so dismissals still work instantly, offline,
|
|
* and on signers without NIP-44 (they just don't sync).
|
|
*/
|
|
|
|
const DISMISS_KIND = 30078; // NIP-78 application-specific data (addressable)
|
|
export const DISMISS_D = 'skatespots:crew-notif-dismissals';
|
|
const LOCAL_KEY = 'skatespots:dismissed-crew-notifs';
|
|
|
|
// ─── Local mirror (offline + no-NIP-44 fallback) ──────────────────────────────
|
|
|
|
function readLocal(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(LOCAL_KEY);
|
|
return raw ? (JSON.parse(raw) as string[]) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeLocal(ids: string[]) {
|
|
try {
|
|
localStorage.setItem(LOCAL_KEY, JSON.stringify(ids));
|
|
} catch {
|
|
// ignore storage errors (private mode, quota, etc.)
|
|
}
|
|
}
|
|
|
|
/** Safely read the signer's nip44 without tripping a throwing getter. */
|
|
function getNip44(user: ReturnType<typeof useCurrentUser>['user']) {
|
|
if (!user?.signer) return undefined;
|
|
try {
|
|
return user.signer.nip44;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function dismissKey(pubkey: string | undefined) {
|
|
return ['crew-dismissals', pubkey] as const;
|
|
}
|
|
|
|
/**
|
|
* Resolve the set of dismissed notification ids for the current user — the
|
|
* union of the encrypted synced list and the local mirror.
|
|
*/
|
|
export function useCrewDismissals() {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
|
|
// Amber (NIP-55) prompts on every decrypt. This query is mounted GLOBALLY via
|
|
// the header NotificationBell, so auto-decrypting here would launch Amber on
|
|
// every navigation/focus — the "Amber flashes on every tap" bug. For an
|
|
// interactive signer we therefore rely on the instant local mirror ONLY and
|
|
// never passively decrypt the synced remote list. Dismissals still sync OUT on
|
|
// the write path (useDismissCrewNotification), and a fresh device simply
|
|
// starts from an empty local mirror — a minor, acceptable degradation vs.
|
|
// storming the user with Amber popups just for walking around the app.
|
|
const interactive = isInteractiveSigner(user, logins);
|
|
|
|
return useQuery({
|
|
queryKey: dismissKey(user?.pubkey),
|
|
// Enabled even when logged out returns just the local mirror (harmless).
|
|
staleTime: interactive ? Infinity : 30_000,
|
|
// Never re-run passive decryption on window focus (Amber returning focus
|
|
// would otherwise re-trigger this and pop Amber again in a loop).
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: !interactive,
|
|
queryFn: async (c): Promise<Set<string>> => {
|
|
const local = new Set(readLocal());
|
|
if (!user?.pubkey) return local;
|
|
|
|
// Interactive signer (Amber): do NOT decrypt passively. Local mirror only.
|
|
if (interactive) return local;
|
|
|
|
const nip44 = getNip44(user);
|
|
// No NIP-44 → can't read the synced list; local mirror is all we have.
|
|
if (!nip44) return local;
|
|
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
|
try {
|
|
const events = await nostr.query(
|
|
[{ kinds: [DISMISS_KIND], authors: [user.pubkey], '#d': [DISMISS_D], limit: 1 }],
|
|
{ signal },
|
|
);
|
|
const ev = events[0];
|
|
if (ev?.content) {
|
|
const decrypted = await nip44.decrypt(user.pubkey, ev.content);
|
|
const parsed = JSON.parse(decrypted) as { ids?: unknown };
|
|
if (Array.isArray(parsed.ids)) {
|
|
for (const id of parsed.ids) {
|
|
if (typeof id === 'string') local.add(id);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Relay hiccup / undecryptable / offline — degrade to the local mirror.
|
|
}
|
|
|
|
// Keep the local mirror in sync with whatever we resolved.
|
|
writeLocal([...local]);
|
|
return local;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dismiss a notification by its event id. Optimistically updates the cache +
|
|
* local mirror immediately, then (best-effort) publishes the encrypted synced
|
|
* list so other devices catch up.
|
|
*
|
|
* INTERACTIVE SIGNERS (Amber): the cross-device encrypted sync is skipped
|
|
* entirely and the dismissal is kept LOCAL-ONLY. Dismissing is a low-stakes,
|
|
* high-frequency action (fired on accept/decline of every crew invite and on
|
|
* every handled join-request), and the encrypted sync costs an extra
|
|
* encrypt + sign — i.e. one or two additional Amber approval screens per tap.
|
|
*
|
|
* Since accepting a crew invite ALREADY requires the membership-attestation
|
|
* signature (the one that actually makes you a member), forcing a *second*
|
|
* Amber round-trip just to clear the notification on your other devices is
|
|
* poor UX and not worth it. So for Amber:
|
|
* • Accept invite → 1 Amber prompt (the membership attestation only)
|
|
* • Decline invite → 0 Amber prompts (local dismissal only)
|
|
* The cross-device "also clear it on my laptop" nicety is silently dropped for
|
|
* Amber; the dismissal still holds instantly and permanently on this device.
|
|
*
|
|
* Non-interactive signers (extension / nsec / bunker) still get the silent
|
|
* encrypted cross-device sync — it's free for them (no approval screens).
|
|
*/
|
|
export function useDismissCrewNotification() {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
const queryClient = useQueryClient();
|
|
|
|
const interactive = isInteractiveSigner(user, logins);
|
|
|
|
const mutate = useCallback(
|
|
async (id: string) => {
|
|
// 1. Optimistic local update (works for everyone, instantly + offline).
|
|
const key = dismissKey(user?.pubkey);
|
|
const current = queryClient.getQueryData<Set<string>>(key) ?? new Set(readLocal());
|
|
const next = new Set(current);
|
|
next.add(id);
|
|
queryClient.setQueryData(key, next);
|
|
writeLocal([...next]);
|
|
|
|
// 2. Interactive signer (Amber): stop here — local-only, NO extra prompt.
|
|
if (interactive) return;
|
|
|
|
// 3. Non-interactive signer: best-effort encrypted NIP-78 cross-device sync.
|
|
const nip44 = getNip44(user);
|
|
if (!user?.pubkey || !nip44) return; // no NIP-44: local-only, no throw
|
|
|
|
try {
|
|
// Data-safety: this list is a single replaceable event whose encrypted
|
|
// content is the WHOLE dismissal set, so we rewrite all of it. If the
|
|
// read query hasn't resolved yet (fresh device: cache empty, local
|
|
// mirror empty), `next` would be just this one id and we'd overwrite —
|
|
// and thus WIPE — the previously-synced dismissals from other devices.
|
|
// So merge in the freshest remote set before publishing. (Low-stakes
|
|
// data — worst case a handled invite re-appears — but we keep the
|
|
// fetch-before-write pattern consistent with Stash/Contacts.)
|
|
const merged = new Set(next);
|
|
try {
|
|
const events = await nostr.query(
|
|
[{ kinds: [DISMISS_KIND], authors: [user.pubkey], '#d': [DISMISS_D], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(6000) },
|
|
);
|
|
const remote = events[0];
|
|
if (remote?.content) {
|
|
const decrypted = await nip44.decrypt(user.pubkey, remote.content);
|
|
const parsed = JSON.parse(decrypted) as { ids?: unknown };
|
|
if (Array.isArray(parsed.ids)) {
|
|
for (const rid of parsed.ids) {
|
|
if (typeof rid === 'string') merged.add(rid);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Couldn't read the remote set — publish the local union anyway; a
|
|
// dismissal is additive, so at worst we under-merge, never wipe silently.
|
|
}
|
|
|
|
// Keep local mirror + cache aligned with what we're about to publish.
|
|
writeLocal([...merged]);
|
|
queryClient.setQueryData(key, merged);
|
|
|
|
const payload = JSON.stringify({ ids: [...merged] });
|
|
const content = await nip44.encrypt(user.pubkey, payload);
|
|
const event = await user.signer.signEvent({
|
|
kind: DISMISS_KIND,
|
|
content,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['d', DISMISS_D],
|
|
['alt', 'Private: dismissed SkateSpots crew notifications'],
|
|
],
|
|
});
|
|
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
|
|
} catch {
|
|
// Publishing failed — the local dismissal still holds on this device.
|
|
}
|
|
},
|
|
[nostr, user, queryClient, interactive],
|
|
);
|
|
|
|
return useMutation({
|
|
mutationFn: mutate,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: dismissKey(user?.pubkey) });
|
|
},
|
|
});
|
|
}
|