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:
parent
fcf8059860
commit
b910141699
7 changed files with 91 additions and 17 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue