Skip to content
Open
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
18 changes: 9 additions & 9 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessages,
threadMessagesPending = false,
threadPanelWidthPx,
threadScrollTargetId,
threadScrollTarget,
threadTypingPubkeys,
threadReplyTargetMessage,
threadUnreadCounts,
Expand Down Expand Up @@ -514,19 +514,18 @@ export const ChannelPane = React.memo(function ChannelPane({
const isOverlay = useIsThreadPanelOverlay();
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
const threadViewMode = useThreadViewMode();
// Focus mode is a wide-viewport-only alternative to the split thread pane:
// narrow viewports keep their existing single-panel / floating-overlay
// behavior untouched. It applies to the thread panel only — channel
// management, agent session and profile panels always use the split pane.
// Focus is a wide-only thread alternative; narrow and non-thread panels keep
// their existing single-panel, overlay, or split-pane behavior.
const useFocusThreadDrawer =
threadViewMode === "focus" &&
useSplitAuxiliaryPane &&
(Boolean(threadHeadMessage) || shouldShowThreadSkeleton);
const { channelIsCovered, markExitComplete } =
useFocusDrawerPresence(useFocusThreadDrawer);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
const { changeThreadViewMode, resolveScrollTarget, scrollTarget } =
useThreadViewModeSwitch({
externalScrollTargetId: threadScrollTargetId,
externalScrollTargetAlignment: threadScrollTarget.alignment,
externalScrollTargetId: threadScrollTarget.id,
onExternalTargetResolved: onThreadScrollTargetResolved,
onModeChange: markExitComplete,
});
Expand Down Expand Up @@ -886,8 +885,9 @@ export const ChannelPane = React.memo(function ChannelPane({
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
scrollTargetHighlights={!scrollTarget.isLayout}
scrollTargetAlignment={scrollTarget.alignment}
scrollTargetId={scrollTarget.id}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
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 { ScrollTargetAlignment } from "@/features/messages/ui/anchoredScrollTarget";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { useChannelFind } from "@/features/search/useChannelFind";
Expand Down Expand Up @@ -156,7 +157,10 @@ export type ChannelPaneProps = {
threadPanelWidthPx: number;
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
threadScrollTarget: {
alignment: ScrollTargetAlignment;
id: string | null;
};
threadUnreadCounts?: ReadonlyMap<string, number>;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadFirstUnreadReplyId?: string | null;
Expand Down
23 changes: 12 additions & 11 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { useMessageProfiles } from "./useMessageProfiles";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useThreadOpenScrollTarget } from "./useThreadOpenScrollTarget";
import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
Expand Down Expand Up @@ -143,9 +144,6 @@ export function ChannelScreen({
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 @@ -476,6 +474,13 @@ export function ChannelScreen({
isThreadMuted,
readStateVersion,
});
const [threadScrollTarget, setThreadScrollTargetId, clearThreadTarget] =
useThreadOpenScrollTarget(
effectiveOpenThreadHeadId,
threadFirstUnreadReplyId,
threadPanelData.visibleReplies,
threadRepliesQuery.isFetching,
);
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
Expand Down Expand Up @@ -642,11 +647,8 @@ export function ChannelScreen({
setThreadReplyTargetId(null);
setEditTargetId(null);
},
[],
[setThreadScrollTargetId],
);
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
}, [clearMessageRouteTarget]);
Expand Down Expand Up @@ -741,6 +743,7 @@ export function ChannelScreen({
setOpenThreadHeadId,
handleCloseAgentSession,
setProfilePanelPubkey,
setThreadScrollTargetId,
]);
const handleToggleMembers = React.useCallback(
() => setIsMembersSidebarOpen((prev) => !prev),
Expand Down Expand Up @@ -922,9 +925,7 @@ export function ChannelScreen({
onSendMessage={handleSendMessage}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={
handleThreadScrollTargetResolved
}
onThreadScrollTargetResolved={clearThreadTarget}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
Expand All @@ -949,7 +950,7 @@ export function ChannelScreen({
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
threadScrollTarget={threadScrollTarget}
threadUnreadCounts={threadUnreadCounts}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
Expand Down
92 changes: 92 additions & 0 deletions desktop/src/features/channels/ui/useThreadOpenScrollTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from "react";
import type { ScrollTargetAlignment } from "@/features/messages/ui/anchoredScrollTarget";

type ThreadScrollTargetState = {
alignment: ScrollTargetAlignment;
id: string | null;
};

type LoadedThreadReply = {
message: { id: string };
};

export function useThreadOpenScrollTarget(
threadHeadId: string | null,
firstUnreadReplyId: string | null,
loadedReplies: readonly LoadedThreadReply[],
isFetchingReplies: boolean,
) {
const [scrollTarget, setScrollTarget] =
React.useState<ThreadScrollTargetState>({ alignment: "center", id: null });
const latchedForHeadRef = React.useRef<string | null>(null);
const lastReplyId = loadedReplies.at(-1)?.message.id ?? null;
const isReadyToLatch =
!isFetchingReplies &&
lastReplyId !== null &&
(firstUnreadReplyId === null ||
loadedReplies.some((entry) => entry.message.id === firstUnreadReplyId));

// Every caller-provided target is an explicit navigation (route target,
// branch expansion, sent reply, or layout-mode restore), so it remains
// centered even when its id happens to equal the unread anchor.
const setScrollTargetId = React.useCallback<
React.Dispatch<React.SetStateAction<string | null>>
>((nextTarget) => {
setScrollTarget((current) => {
const nextId =
typeof nextTarget === "function" ? nextTarget(current.id) : nextTarget;
if (current.id === nextId && current.alignment === "center") {
return current;
}
return { alignment: "center", id: nextId };
});
}, []);

React.useEffect(() => {
if (!threadHeadId) {
latchedForHeadRef.current = null;
return;
}
if (latchedForHeadRef.current === threadHeadId) {
return;
}

// Explicit route/deep-link targets take precedence over the unread anchor.
if (scrollTarget.id !== null) {
latchedForHeadRef.current = threadHeadId;
return;
}

// Wait for the refreshed reply set and divider snapshot to agree on a real
// rendered row. This prevents a transient unread id from being frozen as
// an invisible boundary.
if (!isReadyToLatch) {
return;
}

latchedForHeadRef.current = threadHeadId;
const nextId = firstUnreadReplyId ?? lastReplyId;
if (nextId) {
setScrollTarget({
alignment: firstUnreadReplyId ? "top-with-divider" : "bottom",
id: nextId,
});
}
}, [
firstUnreadReplyId,
isReadyToLatch,
lastReplyId,
scrollTarget.id,
threadHeadId,
]);

const clearScrollTarget = React.useCallback(() => {
setScrollTarget((current) =>
current.id === null && current.alignment === "center"
? current
: { alignment: "center", id: null },
);
}, []);

return [scrollTarget, setScrollTargetId, clearScrollTarget] as const;
}
11 changes: 10 additions & 1 deletion desktop/src/features/channels/ui/useThreadViewModeSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
setThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
import type { ScrollTargetAlignment } from "@/features/messages/ui/anchoredScrollTarget";

export function findTopVisibleThreadMessageId(
body: HTMLElement | null,
Expand Down Expand Up @@ -32,13 +33,15 @@ export function getResolvedThreadTargets({
}

type ThreadViewModeSwitchOptions = {
externalScrollTargetAlignment: ScrollTargetAlignment;
externalScrollTargetId: string | null;
onExternalTargetResolved: () => void;
onModeChange?: (mode: ThreadViewMode) => void;
};

/** Preserves the reply being read while the thread changes presentation. */
export function useThreadViewModeSwitch({
externalScrollTargetAlignment,
externalScrollTargetId,
onExternalTargetResolved,
onModeChange,
Expand Down Expand Up @@ -81,7 +84,13 @@ export function useThreadViewModeSwitch({

return {
changeThreadViewMode,
layoutScrollTargetId,
resolveScrollTarget,
scrollTarget: {
alignment: layoutScrollTargetId
? "center"
: externalScrollTargetAlignment,
id: layoutScrollTargetId ?? externalScrollTargetId,
isLayout: layoutScrollTargetId !== null,
},
};
}
65 changes: 65 additions & 0 deletions desktop/src/features/home/lib/inboxOpenScroll.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveInboxOpenScrollTarget } from "./inboxOpenScroll.ts";

const messages = [
{ createdAt: 10, id: "root" },
{ createdAt: 20, id: "read-reply" },
{ createdAt: 30, id: "first-unread" },
{ createdAt: 40, id: "latest" },
];

test("unread thread opens at the first reply beyond its captured frontier", () => {
assert.deepEqual(
resolveInboxOpenScrollTarget(
{
anchorEventId: "latest",
conversationId: "root",
excludedRootId: "root",
forcedUnreadMessageId: null,
openReadAt: 20,
requestId: 1,
wasUnread: true,
},
messages,
),
{ alignment: "top-with-divider", id: "first-unread" },
);
});

test("already-read thread opens at its physical bottom", () => {
assert.deepEqual(
resolveInboxOpenScrollTarget(
{
anchorEventId: "latest",
conversationId: "root",
excludedRootId: "root",
forcedUnreadMessageId: null,
openReadAt: 40,
requestId: 2,
wasUnread: false,
},
messages,
),
{ alignment: "bottom", id: "latest" },
);
});

test("local mark-unread targets the overridden representative event", () => {
assert.deepEqual(
resolveInboxOpenScrollTarget(
{
anchorEventId: "latest",
conversationId: "root",
excludedRootId: "root",
forcedUnreadMessageId: "latest",
openReadAt: 40,
requestId: 3,
wasUnread: true,
},
messages,
),
{ alignment: "top-with-divider", id: "latest" },
);
});
45 changes: 45 additions & 0 deletions desktop/src/features/home/lib/inboxOpenScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ScrollTargetAlignment } from "@/features/messages/ui/anchoredScrollTarget";

export type InboxOpenScrollIntent = {
anchorEventId: string;
conversationId: string;
excludedRootId: string | null;
forcedUnreadMessageId: string | null;
openReadAt: number | null;
requestId: number;
wasUnread: boolean;
};

export type InboxOpenScrollTarget = {
alignment: ScrollTargetAlignment;
id: string;
};

/**
* Resolves the Inbox-owned layout target from the read state captured before
* opening the row advances its marker. The caller latches this once context
* loading finishes so live arrivals cannot move the open-time boundary.
*/
export function resolveInboxOpenScrollTarget(
intent: InboxOpenScrollIntent,
messages: readonly { createdAt: number; id: string }[],
): InboxOpenScrollTarget | null {
if (messages.length === 0) return null;

if (intent.wasUnread) {
const firstUnread = intent.forcedUnreadMessageId
? messages.find((message) => message.id === intent.forcedUnreadMessageId)
: messages.find(
(message) =>
message.id !== intent.excludedRootId &&
(intent.openReadAt === null ||
message.createdAt > intent.openReadAt),
);
if (firstUnread) {
return { alignment: "top-with-divider", id: firstUnread.id };
}
}

const lastMessage = messages.at(-1);
return lastMessage ? { alignment: "bottom", id: lastMessage.id } : null;
}
Loading
Loading