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:
parent
f73190ab81
commit
3ae05a0c38
8 changed files with 75 additions and 13 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue