Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 97 additions & 57 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -45,12 +46,6 @@ function rememberMentionEvent(
return true;
}

async function disposeLiveSubscriptions(
subscriptions: Array<() => Promise<void>>,
) {
await Promise.allSettled(subscriptions.map((dispose) => dispose()));
}

export function useLiveChannelUpdates(
channels: Channel[],
activeChannelId: string | null,
Expand All @@ -69,8 +64,12 @@ export function useLiveChannelUpdates(
),
[channels],
);
const mentionChannelIds = React.useMemo(
() => [...new Set(channels.map((channel) => channel.id))].sort(),
// 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],
);

Expand Down Expand Up @@ -119,7 +118,7 @@ export function useLiveChannelUpdates(
}, [queryClient]);

React.useEffect(() => {
if (liveChannelIds.size === 0) {
if (!hasLiveChannels) {
return;
}

Expand Down Expand Up @@ -150,76 +149,117 @@ export function useLiveChannelUpdates(
void cleanup();
}
};
}, [liveChannelIds]);
}, [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<string, () => Promise<void>>());
const mentionSubsPubkeyRef = React.useRef<string | null>(null);

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<void>> = [];
let isCancelled = false;
let retryTimeout: ReturnType<typeof setTimeout> | undefined;
let retryAttempt = 0;

const syncSubs = async (): Promise<boolean> => {
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 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] : [],
const targetIds = new Set(
mentionChannelIdsKey ? mentionChannelIdsKey.split(",") : [],
);

if (isDisposed) {
await disposeLiveSubscriptions(nextCleanup);
return;
for (const [channelId, dispose] of activeSubs) {
if (!targetIds.has(channelId)) {
activeSubs.delete(channelId);
void dispose().catch(() => {});
}
}

const firstFailure = settled.find(
(result) => result.status === "rejected",
);
if (!firstFailure) {
cleanup = nextCleanup;
return;
}
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) => {
try {
const dispose = await relayClient.subscribeToChannelMentionEvents(
channelId,
normalizedCurrentPubkey,
handleMentionEvent,
);
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;
};

await disposeLiveSubscriptions(nextCleanup);
if (isDisposed) {
const runSync = async () => {
const ok = await syncSubs();
if (isCancelled) return;
if (ok) {
retryAttempt = 0;
return;
}

console.error(
"Failed to subscribe to all Home mention updates; retrying",
firstFailure.reason,
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 subscribeToMentionChannels();
}, LIVE_MENTION_SUBSCRIPTION_RETRY_MS);
void runSync();
}, delayMs);
};

void subscribeToMentionChannels();
void runSync();

return () => {
isDisposed = true;
isCancelled = true;
if (retryTimeout !== undefined) {
window.clearTimeout(retryTimeout);
}
void disposeLiveSubscriptions(cleanup);
};
}, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]);
}, [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;
};
}, []);
}
2 changes: 1 addition & 1 deletion desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ function createMarkdownComponents(
}

if (hasBlockMedia(childArray)) {
return <div>{children}</div>;
return <div className={paragraphClassName}>{children}</div>;
}

return <p className={paragraphClassName}>{children}</p>;
Expand Down
11 changes: 6 additions & 5 deletions desktop/src/shared/ui/markdownUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<div>` instead of `<p>` to avoid invalid `<p><div>` 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 `<div>` to avoid invalid `<p><div>`
* 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 {
Expand Down