From 56f29e0a295c3e845662ee497c114b4bdebc935a Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 20:02:45 +0700 Subject: [PATCH] Amber: make crew-notification dismissal local-only for interactive signers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting/declining a crew invite (and handling join-requests) fired an encrypted NIP-78 cross-device sync on every dismissal, costing an extra encrypt + sign Amber approval on top of the required membership signature. For interactive (Amber) signers, dismissal is now kept local-only: • Accept invite → 1 Amber prompt (membership attestation only) • Decline invite → 0 Amber prompts (local dismissal only) The dismissal still applies instantly and permanently on-device; only the cross-device "clear it on my other devices too" nicety is dropped for Amber. Extension/nsec/bunker signers keep the silent encrypted sync (free for them). Bumps to 1.2.19 (Android versionCode 10219 auto-derived). Co-authored-by: shakespeare.diy --- package.json | 2 +- src/hooks/useCrewDismissals.ts | 38 ++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 23dcf95..18de607 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.18", + "version": "1.2.19", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useCrewDismissals.ts b/src/hooks/useCrewDismissals.ts index 049d1d6..a84367c 100644 --- a/src/hooks/useCrewDismissals.ts +++ b/src/hooks/useCrewDismissals.ts @@ -3,6 +3,7 @@ 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. @@ -129,15 +130,36 @@ export function useCrewDismissals() { * 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(); - return useMutation({ - mutationFn: async (id: string) => { - // 1. Optimistic local update (works for everyone, instantly). + 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>(key) ?? new Set(readLocal()); const next = new Set(current); @@ -145,7 +167,10 @@ export function useDismissCrewNotification() { queryClient.setQueryData(key, next); writeLocal([...next]); - // 2. Best-effort sync via an encrypted NIP-78 event. + // 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 @@ -166,6 +191,11 @@ export function useDismissCrewNotification() { // 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) }); },