From 68672db858ab8f8c37bcf896f23b9e4c59deda9c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 09:36:46 -0700 Subject: [PATCH 1/4] fix(desktop): render Markdown paragraphs with media as
to avoid

nesting The img renderer always emits block-level markup (lightbox div or video wrapper), so any paragraph containing an image produced an invalid

tree when the paragraph also had text or links. Switch the paragraph renderer to
whenever any image child is present, and preserve paragraph line-height styling on the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- desktop/src/shared/ui/markdown.tsx | 2 +- desktop/src/shared/ui/markdownUtils.ts | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 300c66be7..e24ea05bb 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -221,7 +221,7 @@ function createMarkdownComponents( } if (hasBlockMedia(childArray)) { - return
{children}
; + return
{children}
; } return

{children}

; diff --git a/desktop/src/shared/ui/markdownUtils.ts b/desktop/src/shared/ui/markdownUtils.ts index eb0b353eb..ad609908e 100644 --- a/desktop/src/shared/ui/markdownUtils.ts +++ b/desktop/src/shared/ui/markdownUtils.ts @@ -33,13 +33,14 @@ export function isImageOnlyParagraph(childArray: React.ReactNode[]): boolean { } /** - * Returns true when a paragraph contains block-level media (1+ image/video - * component) and no meaningful text content. These paragraphs must render as - * `
` instead of `

` to avoid invalid `

` nesting. + * Returns true when a paragraph contains any image/video child. The custom + * `img` renderer always emits block-level markup (lightbox/video wrapper), + * so any such paragraph must render as `
` to avoid invalid `

` + * nesting — even when mixed with text or links. */ export function hasBlockMedia(childArray: React.ReactNode[]): boolean { - const { imageChildren, nonImageChildren } = classifyChildren(childArray); - return imageChildren.length >= 1 && nonImageChildren.length === 0; + const { imageChildren } = classifyChildren(childArray); + return imageChildren.length >= 1; } export function shallowArrayEqual(a?: string[], b?: string[]): boolean { From a8956ad225d0bdbc957bedf927bfe2fd6cc0f2a7 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 09:37:16 -0700 Subject: [PATCH 2/4] fix(desktop): collapse per-channel mention subs into one global subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Home-feed mention subscription fanned out one REQ per channel (~100 for heavy users) and tore down + resubscribed whenever the channels list refetched, resulting in hundreds of plugin:websocket|send IPC calls per second and an unresponsive WebView. Replace the fan-out with a single subscription filtered by #p: [currentPubkey] so the relay does the cross-channel matching. Also: - Reset reconnectDelayMs only after replayLiveSubscriptions succeeds, so a replay crash-loop properly backs off instead of retrying at 1s. - Back the global stream subscription effect on a boolean (hasLiveChannels) so channels-query refetches with identical IDs no longer churn the subscription. - Exponential backoff (1s → 30s) on mention-subscription retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../channels/useLiveChannelUpdates.ts | 117 ++++++++---------- desktop/src/shared/api/relayClientSession.ts | 11 +- 2 files changed, 54 insertions(+), 74 deletions(-) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index c04efac2e..e22ab8949 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -14,7 +14,8 @@ export type UseLiveChannelUpdatesOptions = { onLiveMention?: () => void; }; -const LIVE_MENTION_SUBSCRIPTION_RETRY_MS = 1_000; +const LIVE_MENTION_SUBSCRIPTION_RETRY_BASE_MS = 1_000; +const LIVE_MENTION_SUBSCRIPTION_RETRY_MAX_MS = 30_000; function getMessageTimestamp(event: RelayEvent) { return new Date(event.created_at * 1_000).toISOString(); @@ -45,12 +46,6 @@ function rememberMentionEvent( return true; } -async function disposeLiveSubscriptions( - subscriptions: Array<() => Promise>, -) { - await Promise.allSettled(subscriptions.map((dispose) => dispose())); -} - export function useLiveChannelUpdates( channels: Channel[], activeChannelId: string | null, @@ -69,10 +64,10 @@ export function useLiveChannelUpdates( ), [channels], ); - const mentionChannelIds = React.useMemo( - () => [...new Set(channels.map((channel) => channel.id))].sort(), - [channels], - ); + // Effect dep uses a primitive so refetches that produce new Set refs with + // identical contents don't churn subscriptions. The Set is still handy for + // closure reads via useEffectEvent. + const hasLiveChannels = liveChannelIds.size > 0; const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -119,7 +114,7 @@ export function useLiveChannelUpdates( }, [queryClient]); React.useEffect(() => { - if (liveChannelIds.size === 0) { + if (!hasLiveChannels) { return; } @@ -150,76 +145,64 @@ export function useLiveChannelUpdates( void cleanup(); } }; - }, [liveChannelIds]); + }, [hasLiveChannels]); React.useEffect(() => { - if ( - !options.onLiveMention || - normalizedCurrentPubkey.length === 0 || - mentionChannelIds.length === 0 - ) { + if (!options.onLiveMention || normalizedCurrentPubkey.length === 0) { return; } let isDisposed = false; - let cleanup: Array<() => Promise> = []; + let cleanup: (() => Promise) | undefined; let retryTimeout: ReturnType | undefined; - - const subscribeToMentionChannels = async () => { - const settled = await Promise.allSettled( - mentionChannelIds.map((channelId) => - relayClient.subscribeToChannelMentionEvents( - channelId, - normalizedCurrentPubkey, - (event) => { - if (!isDisposed) { - handleMentionEvent(event); - } - }, - ), - ), - ); - - const nextCleanup = settled.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], - ); - - if (isDisposed) { - await disposeLiveSubscriptions(nextCleanup); - return; - } - - const firstFailure = settled.find( - (result) => result.status === "rejected", - ); - if (!firstFailure) { - cleanup = nextCleanup; - return; - } - - await disposeLiveSubscriptions(nextCleanup); - if (isDisposed) { - return; + let retryAttempt = 0; + + const subscribe = async () => { + try { + const dispose = await relayClient.subscribeToMentionsForPubkey( + normalizedCurrentPubkey, + (event) => { + if (!isDisposed) { + handleMentionEvent(event); + } + }, + ); + if (isDisposed) { + void dispose(); + return; + } + cleanup = dispose; + retryAttempt = 0; + } catch (error) { + if (isDisposed) { + return; + } + const delayMs = Math.min( + LIVE_MENTION_SUBSCRIPTION_RETRY_BASE_MS * 2 ** retryAttempt, + LIVE_MENTION_SUBSCRIPTION_RETRY_MAX_MS, + ); + retryAttempt += 1; + console.error( + `Failed to subscribe to Home mention updates; retrying in ${delayMs}ms`, + error, + ); + retryTimeout = window.setTimeout(() => { + retryTimeout = undefined; + void subscribe(); + }, delayMs); } - - console.error( - "Failed to subscribe to all Home mention updates; retrying", - firstFailure.reason, - ); - retryTimeout = window.setTimeout(() => { - retryTimeout = undefined; - void subscribeToMentionChannels(); - }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); }; - void subscribeToMentionChannels(); + void subscribe(); return () => { isDisposed = true; if (retryTimeout !== undefined) { window.clearTimeout(retryTimeout); } - void disposeLiveSubscriptions(cleanup); + if (cleanup) { + void cleanup(); + } }; - }, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]); + }, [normalizedCurrentPubkey, options.onLiveMention]); } diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 721cce432..eb7dda450 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -225,13 +225,12 @@ export class RelayClient { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } - async subscribeToChannelMentionEvents( - channelId: string, + async subscribeToMentionsForPubkey( pubkey: string, onEvent: (event: RelayEvent) => void, ) { return this.subscribe( - this.buildChannelMentionFilter(channelId, pubkey, 50), + this.buildMentionsForPubkeyFilter(pubkey, 50), onEvent, ); } @@ -307,8 +306,8 @@ export class RelayClient { }; }); - this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; await this.replayLiveSubscriptions(); + this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; this.emitReconnectIfNeeded(); } @@ -337,14 +336,12 @@ export class RelayClient { }; } - private buildChannelMentionFilter( - channelId: string, + private buildMentionsForPubkeyFilter( pubkey: string, limit: number, ): RelaySubscriptionFilter { return { kinds: [...HOME_MENTION_EVENT_KINDS], - "#h": [channelId], "#p": [pubkey], limit, since: Math.floor(Date.now() / 1_000), From 300e489de926eae7529cc03c7c3005db26fff140 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 09:55:02 -0700 Subject: [PATCH 3/4] fix(desktop): use diff-based per-channel mention sub manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex adversarial review caught two regressions from the previous relay-flood commit: 1. Removing #h turned subscribeToMentionsForPubkey into a global subscription, and the relay intentionally does not fan out channel-scoped events to global subs (sprout-relay subscription.rs:136). New live mentions silently stopped arriving, breaking the "live forum mentions refetch home feed" integration test. Restore subscribeToChannelMentionEvents with #h. 2. Resetting reconnectDelayMs after replayLiveSubscriptions meant a single transient post-auth replay failure could inherit up to a 30s backoff instead of recovering in 1s. Restore the reset-before-replay position. Replace the per-channel fan-out tear-down+resubscribe pattern with a diff-based subscription manager: a ref-backed Map means channels refetches with identical IDs do zero network work, and a legitimate channel add/remove only subs/unsubs the delta rather than cycling all N subs. Per-channel subscribe failures retry with exponential backoff (1s → 30s). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../channels/useLiveChannelUpdates.ts | 136 ++++++++++++------ desktop/src/shared/api/relayClientSession.ts | 11 +- 2 files changed, 102 insertions(+), 45 deletions(-) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index e22ab8949..39d922c73 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -64,10 +64,14 @@ export function useLiveChannelUpdates( ), [channels], ); - // Effect dep uses a primitive so refetches that produce new Set refs with - // identical contents don't churn subscriptions. The Set is still handy for - // closure reads via useEffectEvent. + // Effect deps use primitive keys so refetches that produce new refs with + // identical contents don't churn subscriptions. The Set/array memos are + // still handy for closure reads via useEffectEvent. const hasLiveChannels = liveChannelIds.size > 0; + const mentionChannelIdsKey = React.useMemo( + () => [...new Set(channels.map((channel) => channel.id))].sort().join(","), + [channels], + ); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -147,62 +151,112 @@ export function useLiveChannelUpdates( }; }, [hasLiveChannels]); + // Subscribe to mention events per channel with a diff-based manager: only + // subscribe newly-added channels and unsubscribe removed ones on each sync. + // The ref survives re-renders so churn-with-identical-IDs does zero work. + const mentionSubsRef = React.useRef(new Map Promise>()); + const mentionSubsPubkeyRef = React.useRef(null); + React.useEffect(() => { if (!options.onLiveMention || normalizedCurrentPubkey.length === 0) { return; } - let isDisposed = false; - let cleanup: (() => Promise) | undefined; + let isCancelled = false; let retryTimeout: ReturnType | undefined; let retryAttempt = 0; - const subscribe = async () => { - try { - const dispose = await relayClient.subscribeToMentionsForPubkey( - normalizedCurrentPubkey, - (event) => { - if (!isDisposed) { - handleMentionEvent(event); - } - }, - ); - if (isDisposed) { - void dispose(); - return; + const syncSubs = async (): Promise => { + const activeSubs = mentionSubsRef.current; + + if ( + mentionSubsPubkeyRef.current !== null && + mentionSubsPubkeyRef.current !== normalizedCurrentPubkey + ) { + const stale = Array.from(activeSubs.values()); + activeSubs.clear(); + await Promise.allSettled(stale.map((dispose) => dispose())); + if (isCancelled) return true; + } + mentionSubsPubkeyRef.current = normalizedCurrentPubkey; + + const targetIds = new Set( + mentionChannelIdsKey ? mentionChannelIdsKey.split(",") : [], + ); + + for (const [channelId, dispose] of activeSubs) { + if (!targetIds.has(channelId)) { + activeSubs.delete(channelId); + void dispose().catch(() => {}); } - cleanup = dispose; + } + + let anyFailed = false; + const additions = Array.from(targetIds) + .filter((channelId) => !activeSubs.has(channelId)) + .map(async (channelId) => { + try { + const dispose = await relayClient.subscribeToChannelMentionEvents( + channelId, + normalizedCurrentPubkey, + (event) => { + if (!isCancelled) handleMentionEvent(event); + }, + ); + if (isCancelled) { + void dispose().catch(() => {}); + return; + } + activeSubs.set(channelId, dispose); + } catch (err) { + anyFailed = true; + console.error( + "Failed to subscribe to mention events", + channelId, + err, + ); + } + }); + await Promise.allSettled(additions); + return !anyFailed; + }; + + const runSync = async () => { + const ok = await syncSubs(); + if (isCancelled) return; + if (ok) { retryAttempt = 0; - } catch (error) { - if (isDisposed) { - return; - } - const delayMs = Math.min( - LIVE_MENTION_SUBSCRIPTION_RETRY_BASE_MS * 2 ** retryAttempt, - LIVE_MENTION_SUBSCRIPTION_RETRY_MAX_MS, - ); - retryAttempt += 1; - console.error( - `Failed to subscribe to Home mention updates; retrying in ${delayMs}ms`, - error, - ); - retryTimeout = window.setTimeout(() => { - retryTimeout = undefined; - void subscribe(); - }, delayMs); + return; } + const delayMs = Math.min( + LIVE_MENTION_SUBSCRIPTION_RETRY_BASE_MS * 2 ** retryAttempt, + LIVE_MENTION_SUBSCRIPTION_RETRY_MAX_MS, + ); + retryAttempt += 1; + retryTimeout = window.setTimeout(() => { + retryTimeout = undefined; + void runSync(); + }, delayMs); }; - void subscribe(); + void runSync(); return () => { - isDisposed = true; + isCancelled = true; if (retryTimeout !== undefined) { window.clearTimeout(retryTimeout); } - if (cleanup) { - void cleanup(); + }; + }, [mentionChannelIdsKey, normalizedCurrentPubkey, options.onLiveMention]); + + React.useEffect(() => { + return () => { + const subs = mentionSubsRef.current; + for (const dispose of subs.values()) { + void dispose().catch(() => {}); } + subs.clear(); + mentionSubsPubkeyRef.current = null; }; - }, [normalizedCurrentPubkey, options.onLiveMention]); + }, []); } diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index eb7dda450..721cce432 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -225,12 +225,13 @@ export class RelayClient { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } - async subscribeToMentionsForPubkey( + async subscribeToChannelMentionEvents( + channelId: string, pubkey: string, onEvent: (event: RelayEvent) => void, ) { return this.subscribe( - this.buildMentionsForPubkeyFilter(pubkey, 50), + this.buildChannelMentionFilter(channelId, pubkey, 50), onEvent, ); } @@ -306,8 +307,8 @@ export class RelayClient { }; }); - await this.replayLiveSubscriptions(); this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; + await this.replayLiveSubscriptions(); this.emitReconnectIfNeeded(); } @@ -336,12 +337,14 @@ export class RelayClient { }; } - private buildMentionsForPubkeyFilter( + private buildChannelMentionFilter( + channelId: string, pubkey: string, limit: number, ): RelaySubscriptionFilter { return { kinds: [...HOME_MENTION_EVENT_KINDS], + "#h": [channelId], "#p": [pubkey], limit, since: Math.floor(Date.now() / 1_000), From 49361ff077c91614a62be73ab89cc49028b564b6 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 20 Apr 2026 10:26:58 -0700 Subject: [PATCH 4/4] fix(desktop): stop dropping events on persistent mention subs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests "live mentions refetch the home feed" and "live forum mentions refetch the home feed" were failing because the onEvent callback for each mention sub was wrapped in an isCancelled check that closed over a local variable from the effect run that created the sub. When the effect re-ran and the sub persisted in the map (the common case — channels unchanged), the original callback was retained, but its isCancelled flag had been flipped to true by the prior effect's cleanup, so events on the long-lived sub were silently dropped. Pass handleMentionEvent directly. It is a useEffectEvent and always captures the latest state; no per-run cancellation wrapper is needed. The outer isCancelled check after subscribe resolves is still required to dispose in-flight subscribes whose effect was torn down. Co-Authored-By: Claude Opus 4.7 (1M context) --- desktop/src/features/channels/useLiveChannelUpdates.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index 39d922c73..e413f6fc9 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -192,6 +192,11 @@ export function useLiveChannelUpdates( } let anyFailed = false; + // Pass handleMentionEvent directly — it's a stable useEffectEvent + // callback. Do NOT wrap in an isCancelled check here: subs persist + // across effect runs (that's the point of the diff manager), so a + // stale isCancelled flag from a prior run would silently drop events + // on long-lived subs. const additions = Array.from(targetIds) .filter((channelId) => !activeSubs.has(channelId)) .map(async (channelId) => { @@ -199,9 +204,7 @@ export function useLiveChannelUpdates( const dispose = await relayClient.subscribeToChannelMentionEvents( channelId, normalizedCurrentPubkey, - (event) => { - if (!isCancelled) handleMentionEvent(event); - }, + handleMentionEvent, ); if (isCancelled) { void dispose().catch(() => {});