fix(amber): stop DM/read hooks re-decrypting on every navigation

For an interactive signer (Amber) each nip44 decrypt opens the app. The DM/NIP-17
read hooks decrypt 2x per gift wrap and DirectMessagesPage mounts two of them, so
navigating to Messages stormed Amber. Strengthened amberSafeQueryOptions for
interactive signers (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect
false) so decryption happens once per session, mirroring useStash; same guard on
usePrivateContacts + useCordnChat. Added a manual Refresh to the Messages header
(Amber gets no polling). Gated useSyncPendingPosts' legacy re-sign loop for
interactive signers.
This commit is contained in:
SkateSpots 2026-10-12 23:28:03 +07:00
parent f73190ab81
commit 3ae05a0c38
8 changed files with 75 additions and 13 deletions

View file

@ -496,6 +496,24 @@ session with **no other memory of this project**. So:
(previously the NIP-65 fallback was effectively dead code because our own DM
relays are always non-empty). `NRelay1.event()` DOES throw on `OK:false`, so
per-relay failures are real; the composer only clears on success.
- **Amber "flashing" on navigation (fixed 2026-10-12).** For an interactive
signer every `nip44.decrypt` opens Amber, and the read hooks decrypt **2 per
gift wrap** (seal + rumor) — up to ~400 calls per query, and
`DirectMessagesPage` mounts **two** such hooks at once
(`useConversations` + `useGroupConversations`). `amberSafeQueryOptions`
(`useDirectMessages.ts`) only disabled polling/focus-refetch and set a **5-min**
staleTime, so navigating back re-decrypted. It now also sets, for interactive
signers: `staleTime: Infinity`, `gcTime: Infinity`, `refetchOnMount: false`,
`refetchOnReconnect: false` — decrypt **once per session**, mirroring
`useStash`/`useSyncedAppData`. Consequence: Amber users don't get live DM
updates, so **`DirectMessagesPage` gained a manual Refresh button**
(`socialUi.dms.refresh`; en/es). Same guard added to `usePrivateContacts` and
`useCordnChat`. Also `useSyncPendingPosts` (`useOfflineCache.ts`) signed queued
**legacy/unsigned** posts in a 30 s loop with **no interactive gate** — it now
skips unsigned posts for interactive signers (pre-signed ones still publish).
⚠️ If you add a new read hook that decrypts, gate it the same way or Amber
will storm on every navigation. (Not yet done: a persisted decrypt cache, so
the first load per app start still decrypts everything.)
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
does **not** use the ContextVM server. AI goes through our own **server-side

View file

@ -49,7 +49,9 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams)
enabled: active,
refetchInterval: active && !interactive ? 5000 : false,
refetchOnWindowFocus: !interactive,
staleTime: interactive ? 5 * 60 * 1000 : 2000,
staleTime: interactive ? Infinity : 2000,
// Amber: decrypt the backlog once per session, not on every crew-page visit.
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
queryFn: async (): Promise<CordnMessage[]> => {
if (!endpoint || !user) return [];
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;

View file

@ -35,11 +35,20 @@ function dmReadRelays(metadata: RelayMetadata): string[] {
function amberSafeQueryOptions(interactive: boolean) {
return {
refetchOnWindowFocus: false as const,
// A relay reconnect would otherwise re-run the decrypt storm.
refetchOnReconnect: !interactive,
// 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,
// For Amber the whole point is: decrypt ONCE, then never again while the
// session lives. A finite staleTime (it was 5 min) still re-decrypted on a
// later navigation; `gcTime: Infinity` + `refetchOnMount: false` keep the
// result cached for the session (mirrors useStash/useSyncedAppData). A
// manual refresh still works — `refetch()` ignores staleTime.
staleTime: interactive ? Infinity : 30000,
...(interactive
? { gcTime: Infinity, refetchOnMount: false as const }
: {}),
};
}

View file

@ -21,6 +21,8 @@ import {
updatePendingPostStatus,
} from '@/lib/offlineCache';
import { useCurrentUser } from './useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login';
import { isInteractiveSigner } from '@/lib/signerCapabilities';
import { useToast } from './useToast';
import { useTranslation } from '@/contexts/language';
import type { NostrEvent } from '@nostrify/nostrify';
@ -191,6 +193,11 @@ export function useSyncPendingPosts() {
const queryClient = useQueryClient();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
// Amber must not be popped from a background interval. New queued posts carry
// an already-signed event; only legacy/unsigned entries need a `signEvent`, and
// for an interactive signer we leave those queued rather than prompt silently.
const interactive = isInteractiveSigner(user, logins);
const { toast } = useToast();
const [isSyncing, setIsSyncing] = useState(false);
const syncingRef = useRef(false);
@ -217,6 +224,9 @@ export function useSyncPendingPosts() {
for (const post of pending) {
if (cancelled) break;
// Interactive signer (Amber): never sign from a background loop.
// Pre-signed posts still publish; legacy unsigned ones stay queued.
if (!post.signedEvent && interactive) continue;
try {
await updatePendingPostStatus(post.id, 'publishing');
const event: NostrEvent =
@ -257,7 +267,7 @@ export function useSyncPendingPosts() {
clearTimeout(timeout);
clearInterval(interval);
};
}, [isOnline, user, nostr, queryClient, toast, t]);
}, [isOnline, user, interactive, nostr, queryClient, toast, t]);
return { isSyncing };
}

View file

@ -35,6 +35,9 @@ export function usePrivateContacts() {
enabled: !!user?.pubkey,
staleTime: interactive ? Infinity : 60 * 1000,
refetchOnWindowFocus: false,
// Amber: keep the decrypted result for the whole session so navigating back
// to Contacts doesn't re-prompt (was still re-decrypting after GC).
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
queryFn: async (c): Promise<string[]> => {
if (!user?.pubkey || !user.signer?.nip44) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);

View file

@ -4075,6 +4075,7 @@ socialUi: {
subtitleShort: 'Encrypted',
newMessage: 'New Message',
groups: 'Groups',
refresh: 'Refresh',
loadFailed: 'Failed to load messages',
emptyTitle: 'No messages yet',
emptyDesc: 'Start a conversation with someone in the community',

View file

@ -4075,6 +4075,7 @@ socialUi: {
subtitleShort: 'Cifrado',
newMessage: 'Nuevo mensaje',
groups: 'Grupos',
refresh: 'Actualizar',
loadFailed: 'No se pudieron cargar los mensajes',
emptyTitle: 'Aún no hay mensajes',
emptyDesc: 'Empieza una conversación con alguien de la comunidad',

View file

@ -27,6 +27,7 @@ import {
ShieldCheck,
X,
Users,
RefreshCw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@ -212,10 +213,10 @@ export default function DirectMessagesPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { data: inbox, isLoading, error, refetch } = useConversations();
const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations();
const conversations = inbox?.conversations;
const undecryptable = inbox?.undecryptable ?? 0;
const { data: groups } = useGroupConversations();
const { data: groups, refetch: refetchGroups } = useGroupConversations();
const { getLastRead } = useDMRead();
const [showNewMessage, setShowNewMessage] = useState(false);
@ -326,13 +327,30 @@ export default function DirectMessagesPage() {
</span>
}
actions={
<Button
onClick={() => setShowNewMessage(true)}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-10 sm:h-auto px-3 sm:px-4"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('socialUi.dms.newMessage')}</span>
</Button>
<div className="flex items-center gap-2">
{/* Manual refresh — Amber doesn't poll (each decrypt is a prompt),
so this is how new messages are pulled in. */}
<Button
variant="outline"
onClick={() => {
void refetch();
void refetchGroups();
}}
disabled={isRefetching}
aria-label={t('socialUi.dms.refresh')}
className="h-10 px-3 sm:h-auto sm:px-4"
>
<RefreshCw className={cn('h-4 w-4', isRefetching && 'animate-spin')} />
<span className="ml-2 hidden sm:inline">{t('socialUi.dms.refresh')}</span>
</Button>
<Button
onClick={() => setShowNewMessage(true)}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-10 sm:h-auto px-3 sm:px-4"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">{t('socialUi.dms.newMessage')}</span>
</Button>
</div>
}
/>