Skip to content
Draft
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export default defineConfig({
"**/home-collapsed-top-chrome.spec.ts",
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/inline-thread-replies.spec.ts",
"**/thread-load-failure.spec.ts",
"**/project-conversation-load-failure.spec.ts",
"**/huddle-thread-load-failure.spec.ts",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const ChannelPane = React.memo(function ChannelPane({
welcomeKickoffStage = null,
welcomeKickoffSettingUp = false,
messages,
inlineThreadController,
threadSummaries,
huddleThreadRepliesError = false,
onRetryHuddleThreadReplies,
Expand Down Expand Up @@ -666,6 +667,7 @@ export const ChannelPane = React.memo(function ChannelPane({
mainEntries={mainTimelineEntries}
threadSummaries={threadSummaries}
messages={visibleMessages}
inlineThreadController={inlineThreadController}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
onDelete={onDelete}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { TimelineMessage } from "@/features/messages/types";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { InlineThreadController } from "@/features/messages/useThreadReplies";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type {
ProfilePanelTab,
Expand Down Expand Up @@ -71,6 +72,7 @@ export type ChannelPaneProps = {
/** The kickoff is still setting up the team — the banner copy reads as setup status. */
welcomeKickoffSettingUp?: boolean;
messages: TimelineMessage[];
inlineThreadController?: InlineThreadController;
threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
/**
* A Huddle transcript flattens summarized reply subtrees into the chat
Expand Down
34 changes: 18 additions & 16 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { useWelcomeKickoffStagePresence } from "@/features/onboarding/useWelcome
import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
import { useCommunities } from "@/features/communities/useCommunities";
import {
mergeMessages,
useChannelMessagesQuery,
useChannelSubscription,
useChannelWindowQuery,
Expand All @@ -53,7 +54,7 @@ import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHead
import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { useInlineThreadReplies, useMarkInlineRepliesRead, useThreadReplies } from "@/features/messages/useThreadReplies";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
Expand Down Expand Up @@ -152,12 +153,8 @@ export function ChannelScreen({
} = useThreadPanelWidth(channelContentWidthPx || undefined);
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
() => new Set<string>(),
);
const [threadScrollTargetId, setThreadScrollTargetId] = React.useState<
string | null
>(null);
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(() => new Set<string>());
const [threadScrollTargetId, setThreadScrollTargetId] = React.useState<string | null>(null);
const [threadReplyTargetId, setThreadReplyTargetId] = React.useState<
string | null
>(null);
Expand Down Expand Up @@ -202,10 +199,8 @@ export function ChannelScreen({
}, [activeChannelId, openThreadHeadId]);
const messagesQuery = useChannelMessagesQuery(activeChannel);
const windowQuery = useChannelWindowQuery(activeChannel);
const threadRepliesQuery = useThreadReplies(
activeChannel,
effectiveOpenThreadHeadId,
);
const threadRepliesQuery = useThreadReplies(activeChannel, effectiveOpenThreadHeadId);
const inlineThreadReplies = useInlineThreadReplies(activeChannel);
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
Expand Down Expand Up @@ -265,6 +260,11 @@ export function ChannelScreen({
targetMessageEvents,
windowStore: windowQuery.data,
});
const resolvedTimelineEvents = React.useMemo(
() =>
inlineThreadReplies.events.reduce(mergeMessages, resolvedMessages),
Comment thread
asymetryk marked this conversation as resolved.
[inlineThreadReplies.events, resolvedMessages],
);
useHuddleReadMarker({
activeChannelId,
activeChannelIsMember: activeChannel?.isMember,
Expand All @@ -283,7 +283,7 @@ export function ChannelScreen({
threadReplyEvents,
);
const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
resolvedMessages,
resolvedTimelineEvents,
threadReplyEvents,
relaySelfPubkey,
);
Expand Down Expand Up @@ -403,7 +403,7 @@ export function ChannelScreen({
const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
resolvedMessages,
resolvedTimelineEvents,
activeChannel,
currentPubkey,
currentProfile?.avatarUrl ?? null,
Expand All @@ -424,7 +424,7 @@ export function ChannelScreen({
personaLookup,
relaySelfPubkey,
respondToLookup,
resolvedMessages,
resolvedTimelineEvents,
],
);
const threadPanelData = useIndependentThreadPanel({
Expand Down Expand Up @@ -453,7 +453,6 @@ export function ChannelScreen({
markRevealedRepliesRead,
openThreadHeadMessage,
threadFirstUnreadReplyId,
threadReplyTargetMessage,
threadReplyUnreadCounts,
threadUnreadCounts,
unreadCount,
Expand All @@ -473,6 +472,7 @@ export function ChannelScreen({
isThreadMuted,
readStateVersion,
});
useMarkInlineRepliesRead(inlineThreadReplies, markRevealedRepliesRead);
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ??
Expand Down Expand Up @@ -671,7 +671,8 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
threadReplyTargetId,
threadReplyTargetMessage,
threadReplyTargetMessage: threadPanelData.replyTargetMessage,
threadMessagesPending: threadRepliesQuery.isPending,
});
const hasAuxiliaryPanel = Boolean(
effectiveOpenThreadHeadId ||
Expand Down Expand Up @@ -883,6 +884,7 @@ export function ChannelScreen({
isSinglePanelView={isSinglePanelView}
isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading}
onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages}
inlineThreadController={inlineThreadReplies.controller}
threadSummaries={threadSummaries}
huddleThreadRepliesError={huddleThreadRepliesError}
onRetryHuddleThreadReplies={onRetryHuddleThreadReplies}
Expand Down
64 changes: 55 additions & 9 deletions desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,45 @@ export function useChannelPaneHandlers({
}, []);

const handleEdit = React.useCallback(
(message: { id: string }) => {
(message: { id: string; rootId?: string | null }) => {
const threadHeadId = message.rootId ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep broadcast edits in the main composer

When the user edits a broadcast reply, isThreadReply deliberately classifies it as a top-level message, but it still carries a rootId; treating every such message as a thread child opens the containing thread before setting the non-thread edit target. In a single-panel or focus-drawer layout, that panel covers the main composer while the thread composer receives no edit target, leaving no visible editor. Only route genuine non-broadcast thread replies through this panel-opening path.

Useful? React with 👍 / 👎.

if (
threadHeadId &&
openThreadHeadIdRef.current !== threadHeadId &&
!requireThreadEditResolution()
)
return;
if (threadHeadId && openThreadHeadIdRef.current !== threadHeadId) {
deferPanelState(() => {
setExpandedThreadReplyIds(new Set());
onOptimisticOpenThreadHeadIdChange(threadHeadId);
setOpenThreadHeadId(threadHeadId);
// Navigation can commit before local target state. Set the child
// target after the containing thread panel has mounted.
deferPanelState(() => {
setEditTargetId(message.id);
setThreadReplyTargetId(threadHeadId);
setThreadScrollTargetId(message.id);
});
});
return;
}
setEditTargetId((current) =>
current === message.id ? null : message.id,
);
setThreadReplyTargetId(openThreadHeadIdRef.current);
setThreadReplyTargetId(threadHeadId ?? openThreadHeadIdRef.current);
if (threadHeadId) setThreadScrollTargetId(message.id);
},
[setEditTargetId, setThreadReplyTargetId],
[
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setThreadReplyTargetId,
setThreadScrollTargetId,
],
);

const handleEditSave = React.useCallback(
Expand Down Expand Up @@ -214,9 +246,17 @@ export function useChannelPaneHandlers({
);

const handleOpenThread = React.useCallback(
(message: { id: string }) => {
(message: { id: string; rootId?: string | null }) => {
if (!requireThreadEditResolution()) return;
if (openThreadHeadIdRef.current === message.id) {
const threadHeadId = message.rootId ?? message.id;
const replyTargetId = message.rootId ? message.id : threadHeadId;
if (openThreadHeadIdRef.current === threadHeadId) {
if (message.rootId) {
setThreadReplyTargetId(replyTargetId);
setThreadScrollTargetId(replyTargetId);
setEditTargetId(null);
return;
}
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
Expand All @@ -229,11 +269,17 @@ export function useChannelPaneHandlers({
}

deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(message.id);
setOpenThreadHeadId(message.id);
setThreadReplyTargetId(message.id);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
onOptimisticOpenThreadHeadIdChange(threadHeadId);
setOpenThreadHeadId(threadHeadId);
if (message.rootId) {
// Navigation can commit before local target state. Set the child
// target after the containing thread panel has mounted.
deferPanelState(() => {
setThreadReplyTargetId(replyTargetId);
setThreadScrollTargetId(replyTargetId);
});
}
});
setEditTargetId(null);
},
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/channels/useThreadTargetSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function useThreadTargetSync({
setThreadScrollTargetId,
threadReplyTargetId,
threadReplyTargetMessage,
threadMessagesPending,
}: {
clearOptimisticThreadOverride: () => void;
editTargetId: string | null;
Expand All @@ -37,6 +38,7 @@ export function useThreadTargetSync({
setThreadScrollTargetId: (id: string | null) => void;
threadReplyTargetId: string | null;
threadReplyTargetMessage: TimelineMessage | null;
threadMessagesPending: boolean;
}) {
React.useEffect(() => {
if (openThreadHeadId && !openThreadHeadMessage) {
Expand All @@ -55,6 +57,10 @@ export function useThreadTargetSync({
return;
}

if (threadMessagesPending) {
return;
}

if (threadReplyTargetId && !threadReplyTargetMessage) {
setThreadReplyTargetId(openThreadHeadMessage?.id ?? null);
}
Expand All @@ -74,6 +80,7 @@ export function useThreadTargetSync({
setThreadReplyTargetId,
setThreadScrollTargetId,
threadReplyTargetId,
threadMessagesPending,
threadReplyTargetMessage,
]);
}
Loading