Crew dismissals: merge remote set before sync (close clobber pattern)

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>
This commit is contained in:
shakespeare.diy 2026-09-07 20:46:46 +07:00
parent a31583079b
commit 8affd02bcf
2 changed files with 35 additions and 2 deletions

View file

@ -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": {

View file

@ -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,