diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index dec95f61cf..6b5dd5b4ea 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + ["src/shared/api/relayClientSession.ts", 792], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore; temporarily raised to cover subscription replay hardening on human-reply ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 570], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + persona pack commands ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index fab898975b..52366919e7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,4 @@ import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; - import { useAppShell } from "@/app/AppShellContext"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; @@ -22,22 +20,17 @@ import { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; -import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; import { collectMessageAuthorPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; -import { - getChannelIdFromTags, - getThreadReference, -} from "@/features/messages/lib/threading"; import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; +import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors"; import { useChannelTyping } from "@/features/messages/useChannelTyping"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; -import { getEventById } from "@/shared/api/tauri"; import type { Channel, Identity, @@ -79,7 +72,6 @@ export function ChannelScreen({ targetMessageEvent, targetMessageId, }: ChannelScreenProps) { - const queryClient = useQueryClient(); const { markChannelRead, openChannelManagement } = useAppShell(); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [threadHeadPath, setThreadHeadPath] = React.useState([]); @@ -159,13 +151,12 @@ export function ChannelScreen({ [openThreadHeadId, typingEntries], ); const messageProfilePubkeys = React.useMemo( - () => - [ - ...new Set([ - ...messageAuthorPubkeys, - ...typingEntries.map((entry) => entry.pubkey), - ]), - ], + () => [ + ...new Set([ + ...messageAuthorPubkeys, + ...typingEntries.map((entry) => entry.pubkey), + ]), + ], [messageAuthorPubkeys, typingEntries], ); const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { @@ -309,7 +300,6 @@ export function ChannelScreen({ shouldLoadTimeline && (messagesQuery.isPending || (messagesQuery.isFetching && resolvedMessages.length === 0)); - const requestedAncestorIdsRef = React.useRef>(new Set()); const resetComposerTargets = React.useCallback( (_channelId: string | null) => { setThreadHeadPath([]); @@ -318,12 +308,6 @@ export function ChannelScreen({ }, [], ); - const resetRequestedAncestors = React.useCallback( - (_channelId: string | null) => { - requestedAncestorIdsRef.current.clear(); - }, - [], - ); React.useEffect(() => { resetComposerTargets(activeChannelId); @@ -351,91 +335,11 @@ export function ChannelScreen({ editTargetMessage, openThreadHeadId, openThreadHeadMessage, - threadHeadPath, threadReplyTargetId, threadReplyTargetMessage, ]); - React.useEffect(() => { - resetRequestedAncestors(activeChannelId); - }, [activeChannelId, resetRequestedAncestors]); - - React.useEffect(() => { - if (!activeChannel || activeChannel.channelType === "forum") { - return; - } - - const knownEvents = new Map( - resolvedMessages.map((message) => [message.id, message]), - ); - const missingAncestorIds = new Set(); - - for (const message of resolvedMessages) { - const thread = getThreadReference(message.tags); - - for (const eventId of [thread.parentId, thread.rootId]) { - if ( - !eventId || - knownEvents.has(eventId) || - requestedAncestorIdsRef.current.has(eventId) - ) { - continue; - } - - missingAncestorIds.add(eventId); - } - } - - if (missingAncestorIds.size === 0) { - return; - } - - for (const eventId of missingAncestorIds) { - requestedAncestorIdsRef.current.add(eventId); - } - - const maxRequestedAncestors = 500; - if (requestedAncestorIdsRef.current.size > maxRequestedAncestors) { - const excess = - requestedAncestorIdsRef.current.size - maxRequestedAncestors; - let removed = 0; - for (const id of requestedAncestorIdsRef.current) { - if (removed >= excess) { - break; - } - requestedAncestorIdsRef.current.delete(id); - removed++; - } - } - - let isCancelled = false; - - void Promise.all( - [...missingAncestorIds].map(async (eventId) => { - try { - const event = await getEventById(eventId); - - if ( - isCancelled || - getChannelIdFromTags(event.tags) !== activeChannel.id - ) { - return; - } - - queryClient.setQueryData( - channelMessagesKey(activeChannel.id), - (current = []) => mergeMessages(current, event), - ); - } catch (error) { - console.error("Failed to load ancestor event", eventId, error); - } - }), - ); - - return () => { - isCancelled = true; - }; - }, [activeChannel, queryClient, resolvedMessages]); + useLoadMissingAncestors(activeChannel, resolvedMessages); const activeChannelEphemeralBadge = activeChannelEphemeralDisplay ? ( (); + const recentUniqueParticipants = new Map< + string, + TimelineThreadSummaryParticipant + >(); for (let index = replies.length - 1; index >= 0; index -= 1) { const reply = replies[index]; @@ -107,7 +110,10 @@ export function buildMainTimelineEntries( .map((message) => { return { message, - summary: buildSummaryForDirectReplies(message.id, directChildrenByParentId), + summary: buildSummaryForDirectReplies( + message.id, + directChildrenByParentId, + ), }; }); } @@ -140,9 +146,9 @@ export function buildThreadPanelData( const directChildrenByParentId = buildDirectChildrenByParentId(messages); const normalizedThreadHead = normalizeHeadMessage(threadHead); - const directReplies = (directChildrenByParentId.get(openThreadHeadId) ?? []).map( - (message) => normalizeBranchReply(message), - ); + const directReplies = ( + directChildrenByParentId.get(openThreadHeadId) ?? [] + ).map((message) => normalizeBranchReply(message)); const visibleReplies = directReplies.map((message) => ({ message, summary: buildSummaryForDirectReplies(message.id, directChildrenByParentId), @@ -151,7 +157,7 @@ export function buildThreadPanelData( const replyTargetInBranch = threadReplyTargetId === threadHead.id ? normalizedThreadHead - : messageById.get(threadReplyTargetId ?? "") ?? null; + : (messageById.get(threadReplyTargetId ?? "") ?? null); return { threadHead: normalizedThreadHead, diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 6e46ad5609..78badbe476 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -74,7 +74,6 @@ export function MessageThreadPanel({ threadHead, threadReplies, threadTypingPubkeys, - totalReplyCount, }: MessageThreadPanelProps) { if (!threadHead) { return null; @@ -122,7 +121,10 @@ export function MessageThreadPanel({ -
+
0 ? "-ml-2" : ""} - style={{ zIndex: 10 - index }} - > +
0 ? "-ml-2" : ""} style={{ zIndex: 10 - index }}> >(new Set()); + const previousChannelIdRef = React.useRef(null); + + React.useEffect(() => { + const activeChannelId = activeChannel?.id ?? null; + if (previousChannelIdRef.current === activeChannelId) { + return; + } + previousChannelIdRef.current = activeChannelId; + requestedAncestorIdsRef.current.clear(); + }, [activeChannel?.id]); + + React.useEffect(() => { + if (!activeChannel || activeChannel.channelType === "forum") { + return; + } + + const knownEvents = new Map( + resolvedMessages.map((message) => [message.id, message]), + ); + const missingAncestorIds = new Set(); + + for (const message of resolvedMessages) { + const thread = getThreadReference(message.tags); + + for (const eventId of [thread.parentId, thread.rootId]) { + if ( + !eventId || + knownEvents.has(eventId) || + requestedAncestorIdsRef.current.has(eventId) + ) { + continue; + } + + missingAncestorIds.add(eventId); + } + } + + if (missingAncestorIds.size === 0) { + return; + } + + for (const eventId of missingAncestorIds) { + requestedAncestorIdsRef.current.add(eventId); + } + + const maxRequestedAncestors = 500; + if (requestedAncestorIdsRef.current.size > maxRequestedAncestors) { + const excess = + requestedAncestorIdsRef.current.size - maxRequestedAncestors; + let removed = 0; + for (const id of requestedAncestorIdsRef.current) { + if (removed >= excess) { + break; + } + requestedAncestorIdsRef.current.delete(id); + removed++; + } + } + + let isCancelled = false; + + void Promise.all( + [...missingAncestorIds].map(async (eventId) => { + try { + const event = await getEventById(eventId); + + if ( + isCancelled || + getChannelIdFromTags(event.tags) !== activeChannel.id + ) { + return; + } + + queryClient.setQueryData( + channelMessagesKey(activeChannel.id), + (current = []) => mergeMessages(current, event), + ); + } catch (error) { + console.error("Failed to load ancestor event", eventId, error); + } + }), + ); + + return () => { + isCancelled = true; + }; + }, [activeChannel, queryClient, resolvedMessages]); +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 1a4ef9fe93..6069ceea13 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -21,10 +21,10 @@ import { } from "@/shared/api/relayClientShared"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; -const RECONNECT_BASE_DELAY_MS = 1_000; -const RECONNECT_MAX_DELAY_MS = 30_000; -const RECONNECT_REPLAY_SKEW_SECS = 5; -const EVENT_BATCH_MS = 16; +const RECONNECT_BASE_DELAY_MS = 1_000, + RECONNECT_MAX_DELAY_MS = 30_000; +const RECONNECT_REPLAY_SKEW_SECS = 5, + EVENT_BATCH_MS = 16; export class RelayClient { private wsId: number | null = null; diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 87af439819..5262893213 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -331,9 +331,9 @@ test("opens a branch-only thread panel from the reply action", async ({ await expect(threadPanel.getByTestId("message-thread-head")).toContainText( firstReply, ); - await expect(threadPanel.getByTestId("message-thread-head")).not.toContainText( - "Welcome to #general", - ); + await expect( + threadPanel.getByTestId("message-thread-head"), + ).not.toContainText("Welcome to #general"); await expect(threadReplies).not.toContainText(siblingReply); await threadComposer.fill(nestedReply);