Amber: make crew-notification dismissal local-only for interactive signers

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 20:02:45 +07:00
parent c9b0571de9
commit 56f29e0a29
2 changed files with 35 additions and 5 deletions

View file

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

View file

@ -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<Set<string>>(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) });
},