fix(amber): stop passive decryption from storming Amber on navigation (1.2.16)

With Amber as signer, every nip44 decrypt is a foreground intent (visible
popup). Several hooks decrypt inside auto-running queries, so Amber flashed on
navigation/focus and (worst case) storms — one popup per encrypted event, and
polling queries re-fired every few seconds.

New helper isInteractiveSigner() (signerCapabilities.ts) detects the Amber
(x-amber) signer, where each crypto op prompts the user. Hooks now gate passive
decryption for interactive signers only; extension/nsec/bunker keep their
silent, live-polling behaviour.

Fixes:
- useCrewDismissals (GLOBAL via header NotificationBell — the main offender):
  Amber users now use the instant localStorage mirror only, never passively
  decrypt the synced list; disabled refetchOnWindowFocus + Infinity staleTime.
  Dismissals still sync OUT on the write path.
- useDirectMessages (all 4 DM query hooks): added amberSafeQueryOptions() —
  no background polling and no focus refetch for Amber (these decrypt up to
  ~200 gift wraps × 2 each); silent signers keep the 60s poll.
- useStash / usePrivateContacts: disabled focus refetch + Infinity staleTime
  for Amber so the single decrypt runs once, not on every focus.
- useCordnChat: dropped the 5s live poll for Amber (was a popup every 5s);
  silent signers keep it.

Scanned every signEvent call site: all are inside mutations/user callbacks
(zaps, invites, DMs, chat, follows, purchases, NIP-98 auth) — no passive
auto-signing anywhere. Deliberate user actions still prompt Amber as intended.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 14:57:35 +07:00
parent fcf8059860
commit b910141699
7 changed files with 91 additions and 17 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.15",
"version": "1.2.16",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import {
buildEnvelope,
fetchGroupMessages,
@ -38,12 +38,18 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams)
const canEncrypt = checkNip44(user, logins);
const active = !!endpoint && !!user && enabled && canEncrypt;
// Amber prompts on every message decrypt. A 5s live poll would fire an Amber
// popup every 5 seconds — unusable. For Amber we disable the poll (the user
// refreshes/sends to pull new messages) and stop focus refetches; silent
// signers keep the live 5s poll.
const interactive = isInteractiveSigner(user, logins);
const query = useQuery({
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
enabled: active,
refetchInterval: active ? 5000 : false,
staleTime: 2000,
refetchInterval: active && !interactive ? 5000 : false,
refetchOnWindowFocus: !interactive,
staleTime: interactive ? 5 * 60 * 1000 : 2000,
queryFn: async (): Promise<CordnMessage[]> => {
if (!endpoint || !user) return [];
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;

View file

@ -1,6 +1,8 @@
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';
/**
* Cross-device "dismissed crew notifications" set.
@ -65,15 +67,33 @@ export function dismissKey(pubkey: string | undefined) {
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: 30_000,
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;

View file

@ -7,7 +7,26 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
/**
* React Query options that keep an interactive signer (Amber) from being stormed
* with decrypt popups. Each DM query decrypts up to hundreds of gift wraps, and
* every decrypt is an Amber intent — so with Amber we must NOT poll and must NOT
* re-run on window focus (Amber handing focus back would re-trigger the query
* and pop Amber again). With a silent signer (extension/nsec/bunker) we keep the
* live polling behaviour. Callers spread this over their base options.
*/
function amberSafeQueryOptions(interactive: boolean) {
return {
refetchOnWindowFocus: false as const,
// No background polling for Amber (each poll = a decrypt storm). Silent
// signers keep a 60s poll for near-live updates.
refetchInterval: interactive ? (false as const) : 60000,
// Long stale time for Amber so navigating back to a chat doesn't re-decrypt.
staleTime: interactive ? 5 * 60 * 1000 : 30000,
};
}
// NIP-17 Message Types
export interface DirectMessage {
@ -333,8 +352,7 @@ export function useConversation(otherPubkey: string) {
return messages.sort((a, b) => a.createdAt - b.createdAt);
},
enabled: nip44Available && !!otherPubkey,
staleTime: 30000,
refetchInterval: 30000, // Poll for new messages
...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
});
}
@ -459,8 +477,7 @@ export function useConversations() {
);
},
enabled: nip44Available,
staleTime: 30000,
refetchInterval: 60000, // Poll for new conversations
...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
});
}
@ -609,8 +626,7 @@ export function useGroupConversations() {
);
},
enabled: nip44Available,
staleTime: 30000,
refetchInterval: 60000,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
});
}
@ -715,7 +731,6 @@ export function useGroupConversation(participants: string[]) {
return messages.sort((a, b) => a.createdAt - b.createdAt);
},
enabled: nip44Available && participants.length > 0,
staleTime: 30000,
refetchInterval: 30000,
...amberSafeQueryOptions(isInteractiveSigner(user, logins)),
});
}

View file

@ -1,6 +1,8 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isInteractiveSigner } from '@/lib/signerCapabilities';
/**
* Private, encrypted contacts list (Armada-style).
@ -24,11 +26,15 @@ interface EncryptedContacts {
export function usePrivateContacts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
// Amber prompts on the decrypt below — don't re-run it on every window focus.
const interactive = isInteractiveSigner(user, logins);
return useQuery({
queryKey: ['private-contacts', user?.pubkey],
enabled: !!user?.pubkey,
staleTime: 60 * 1000,
staleTime: interactive ? Infinity : 60 * 1000,
refetchOnWindowFocus: false,
queryFn: async (c): Promise<string[]> => {
if (!user?.pubkey || !user.signer?.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);

View file

@ -4,7 +4,7 @@ import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
// Stash item stored in encrypted NIP-51 list
export interface StashItem {
@ -55,6 +55,9 @@ export function useStash() {
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
// Amber prompts on the stash decrypt — keep it from re-running on window focus
// (e.g. every time Amber hands focus back) so it decrypts once, not repeatedly.
const interactive = isInteractiveSigner(user, logins);
// Fetch and decrypt stash
const { data: stash, isLoading, error } = useQuery({
@ -95,7 +98,8 @@ export function useStash() {
}
},
enabled: !!user?.pubkey && hasNip44,
staleTime: 60000, // 1 minute
staleTime: interactive ? Infinity : 60000,
refetchOnWindowFocus: false,
});
// Save stash (encrypt and publish)

View file

@ -64,6 +64,29 @@ export function hasNip44(
return false;
}
/**
* True when the active signer prompts the user for EVERY cryptographic op —
* i.e. Amber (NIP-55), where each sign/encrypt/decrypt launches Amber's
* Approve/Reject screen as a foreground intent.
*
* This matters for passive decryption: with an extension/nsec/bunker signer,
* decrypting many events in a background query is silent and instant, so it's
* fine to do it automatically (on mount/focus). With Amber, the SAME code
* launches Amber once per event — so Amber visibly flashes on navigation and
* can storm the user with popups. Callers use this to gate/defer passive
* decryption (only decrypt on explicit user intent, don't auto-refetch) when
* the signer is interactive.
*/
export function isInteractiveSigner(
user: MaybeUser | null | undefined,
logins: readonly MaybeLogin[],
): boolean {
const login = resolveLogin(user, logins);
if (!login) return false;
// Amber (NIP-55) is the only signer that requires a user interaction per op.
return login.type === 'x-amber';
}
/** True if the user's signer can do NIP-04 encryption. Never throws. */
export function hasNip04(
user: MaybeUser | null | undefined,