fix(desktop): unblock nested thread panel CI

Clean up the nested thread panel follow-up changes so the desktop checks pass again by removing the unused thread prop, extracting ancestor loading out of ChannelScreen, and aligning the affected files with the current formatting and size guardrails.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-13 20:33:44 -04:00
parent b4b78ac186
commit f04b7c807e
8 changed files with 139 additions and 124 deletions

View File

@@ -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

View File

@@ -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<string[]>([]);
@@ -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<Set<string>>(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<string>();
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<RelayEvent[]>(
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 ? (
<EphemeralChannelBadge

View File

@@ -42,7 +42,10 @@ function normalizeBranchReply(message: TimelineMessage): TimelineMessage {
function buildSummaryParticipants(
replies: TimelineMessage[],
): TimelineThreadSummaryParticipant[] {
const recentUniqueParticipants = new Map<string, TimelineThreadSummaryParticipant>();
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,

View File

@@ -74,7 +74,6 @@ export function MessageThreadPanel({
threadHead,
threadReplies,
threadTypingPubkeys,
totalReplyCount,
}: MessageThreadPanelProps) {
if (!threadHead) {
return null;
@@ -122,7 +121,10 @@ export function MessageThreadPanel({
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto" data-testid="message-thread-body">
<div
className="min-h-0 flex-1 overflow-y-auto"
data-testid="message-thread-body"
>
<div className="px-3 py-3" data-testid="message-thread-head">
<MessageRow
activeReplyTargetId={replyTargetId}

View File

@@ -13,10 +13,7 @@ function ParticipantAvatar({
index: number;
}) {
return (
<div
className={index > 0 ? "-ml-2" : ""}
style={{ zIndex: 10 - index }}
>
<div className={index > 0 ? "-ml-2" : ""} style={{ zIndex: 10 - index }}>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="rounded-full border-2 border-background"

View File

@@ -0,0 +1,106 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { mergeMessages } from "@/features/messages/hooks";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import { getEventById } from "@/shared/api/tauri";
import type { Channel, RelayEvent } from "@/shared/api/types";
export function useLoadMissingAncestors(
activeChannel: Channel | null,
resolvedMessages: RelayEvent[],
) {
const queryClient = useQueryClient();
const requestedAncestorIdsRef = React.useRef<Set<string>>(new Set());
const previousChannelIdRef = React.useRef<string | null>(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<string>();
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<RelayEvent[]>(
channelMessagesKey(activeChannel.id),
(current = []) => mergeMessages(current, event),
);
} catch (error) {
console.error("Failed to load ancestor event", eventId, error);
}
}),
);
return () => {
isCancelled = true;
};
}, [activeChannel, queryClient, resolvedMessages]);
}

View File

@@ -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;

View File

@@ -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);