From 8affd02bcff673146ab084b8c1f4033cca13a80c Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 20:46:46 +0700 Subject: [PATCH] Crew dismissals: merge remote set before sync (close clobber pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/hooks/useCrewDismissals.ts | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 994f2bb..688716d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.24", + "version": "1.2.25", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useCrewDismissals.ts b/src/hooks/useCrewDismissals.ts index a84367c..dd4a0a6 100644 --- a/src/hooks/useCrewDismissals.ts +++ b/src/hooks/useCrewDismissals.ts @@ -175,7 +175,40 @@ export function useDismissCrewNotification() { if (!user?.pubkey || !nip44) return; // no NIP-44: local-only, no throw try { - const payload = JSON.stringify({ ids: [...next] }); + // 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,