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
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 @@ -140,6 +140,7 @@ export const ChannelPane = React.memo(function ChannelPane({
threadMessages,
threadPanelWidthPx,
threadScrollTargetId,
threadScrollTargetHighlighted,
threadTypingPubkeys,
threadReplyTargetMessage,
threadUnreadCounts,
Expand Down Expand Up @@ -827,6 +828,7 @@ export const ChannelPane = React.memo(function ChannelPane({
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
scrollTargetHighlighted={threadScrollTargetHighlighted}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export type ChannelPaneProps = {
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
threadScrollTargetHighlighted: boolean;
threadUnreadCounts?: ReadonlyMap<string, number>;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadFirstUnreadReplyId?: string | null;
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ export function ChannelScreen({
const [threadScrollTargetId, setThreadScrollTargetId] = React.useState<
string | null
>(null);
const [threadHighlightTargetId, setThreadHighlightTargetId] = React.useState<
string | null
>(null);
const threadScrollTargetHighlighted =
threadScrollTargetId !== null &&
threadScrollTargetId === threadHighlightTargetId;
const [threadReplyTargetId, setThreadReplyTargetId] = React.useState<
string | null
>(null);
Expand Down Expand Up @@ -667,6 +673,7 @@ export function ChannelScreen({
);
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
setThreadHighlightTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
Expand All @@ -683,6 +690,7 @@ export function ChannelScreen({
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadHighlightTargetId,
setThreadScrollTargetId,
targetMessageId,
timelineMessages,
Expand Down Expand Up @@ -967,6 +975,7 @@ export function ChannelScreen({
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
threadScrollTargetHighlighted={threadScrollTargetHighlighted}
threadUnreadCounts={threadUnreadCounts}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/channels/ui/useChannelRouteTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function useChannelRouteTarget({
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadHighlightTargetId,
setThreadScrollTargetId,
targetMessageId,
timelineMessages,
Expand All @@ -73,6 +74,9 @@ export function useChannelRouteTarget({
setOpenThreadHeadId: PanelValueSetter;
setProfilePanelPubkey: PanelValueSetter;
setThreadReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setThreadHighlightTargetId: React.Dispatch<
React.SetStateAction<string | null>
>;
setThreadScrollTargetId: React.Dispatch<React.SetStateAction<string | null>>;
targetMessageId: string | null;
timelineMessages: TimelineMessage[];
Expand Down Expand Up @@ -149,6 +153,7 @@ export function useChannelRouteTarget({
setEditTargetId(null);
setOpenThreadHeadId(routeTarget.threadHeadId, { replace: true });
setThreadReplyTargetId(routeTarget.threadHeadId);
setThreadHighlightTargetId(targetMessageId);
setThreadScrollTargetId(targetMessageId);
setExpandedThreadReplyIds(routeTarget.expandedReplyIds);
handledThreadRouteTargetRef.current = targetKey;
Expand All @@ -161,6 +166,7 @@ export function useChannelRouteTarget({
setOpenThreadHeadId,
setProfilePanelPubkey,
setThreadReplyTargetId,
setThreadHighlightTargetId,
setThreadScrollTargetId,
targetMessageId,
timelineMessageById,
Expand Down
29 changes: 19 additions & 10 deletions desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ type MessageThreadPanelProps = {
profiles?: UserProfileLookup;
replyTargetMessage: TimelineMessage | null;
scrollTargetId: string | null;
scrollTargetHighlighted?: boolean;
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadUnreadCount?: number;
Expand Down Expand Up @@ -329,6 +330,7 @@ export function MessageThreadPanel({
profiles,
replyTargetMessage,
scrollTargetId,
scrollTargetHighlighted = false,
threadHead,
threadHeadVideoReviewContext,
threadReplies,
Expand Down Expand Up @@ -585,16 +587,22 @@ export function MessageThreadPanel({
threadHead,
]);

const { isAtBottom, newMessageCount, onScroll, scrollToBottom } =
useAnchoredScroll({
channelId: threadHeadId,
contentRef: threadContentRef,
isLoading: repliesRenderState === "pending",
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
});
const {
highlightedMessageId,
isAtBottom,
newMessageCount,
onScroll,
scrollToBottom,
} = useAnchoredScroll({
channelId: threadHeadId,
contentRef: threadContentRef,
highlightTarget: scrollTargetHighlighted,
isLoading: repliesRenderState === "pending",
messages: threadMessages,
onTargetReached: onScrollTargetResolved,
scrollContainerRef: threadBodyRef,
targetMessageId: scrollTargetId,
});

if (!threadHead) {
return null;
Expand Down Expand Up @@ -748,6 +756,7 @@ export function MessageThreadPanel({
isDirectChildOfHighlightedBranch
}
highlightThreadLineDepths={highlightedLineDepths}
highlighted={entry.message.id === highlightedMessageId}
hoverBackground={!entry.summary}
huddleMemberPubkeys={huddleMemberPubkeys}
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
Expand Down
28 changes: 19 additions & 9 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ type UseAnchoredScrollOptions = {
* arrivals and to seed/refresh the anchor pre-render. */
messages: Array<{ id: string }>;

/** When set, scroll to and highlight this message on mount and on change. */
/** When set, scroll to this message on mount and on change. */
targetMessageId?: string | null;
/** Whether an automatic target scroll should also pulse the message. */
highlightTarget?: boolean;
onTargetReached?: (messageId: string) => void;
};

Expand Down Expand Up @@ -149,6 +151,7 @@ export function useAnchoredScroll({
messages,

targetMessageId = null,
highlightTarget = true,
onTargetReached,
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
// Anchor lives in a ref because it must survive renders and is updated
Expand All @@ -166,7 +169,7 @@ export function useAnchoredScroll({
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const prevMessagesRef = React.useRef<Array<{ id: string }>>([]);
const handledTargetIdRef = React.useRef<string | null>(null);
const handledTargetKeyRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(null);
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
// on channel switch (the channelId reset effect clears it).
Expand Down Expand Up @@ -196,7 +199,7 @@ export function useAnchoredScroll({
prevFirstMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
prevMessagesRef.current = [];
handledTargetIdRef.current = null;
handledTargetKeyRef.current = null;
forceBottomOnNextAppendRef.current = false;
settlingRef.current = false;
if (highlightTimeoutRef.current !== null) {
Expand Down Expand Up @@ -352,8 +355,12 @@ export function useAnchoredScroll({
// render or two later. If centering fails now, leave the timeline at
// its default position and let the post-mount target effect (keyed on
// `messages`) retry once the row lands, rather than marking it handled.
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
handledTargetIdRef.current = targetMessageId;
if (
scrollToMessageImperative(targetMessageId, {
highlight: highlightTarget,
})
) {
handledTargetKeyRef.current = `${targetMessageId}:${highlightTarget}`;
onTargetReached?.(targetMessageId);
} else {
pinToBottomOnMount();
Expand Down Expand Up @@ -442,6 +449,7 @@ export function useAnchoredScroll({
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
}, [
highlightTarget,
isLoading,
messages,
onTargetReached,
Expand Down Expand Up @@ -489,10 +497,11 @@ export function useAnchoredScroll({
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
React.useEffect(() => {
if (!targetMessageId) {
handledTargetIdRef.current = null;
handledTargetKeyRef.current = null;
return;
}
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
const targetKey = `${targetMessageId}:${highlightTarget}`;
if (handledTargetKeyRef.current === targetKey || isLoading) return;
if (!hasInitializedRef.current) return; // initial-mount path will handle.

const container = scrollContainerRef.current;
Expand All @@ -506,10 +515,11 @@ export function useAnchoredScroll({
// each `messages` commit and retries until the row exists.
return;
}
handledTargetIdRef.current = targetMessageId;
scrollToMessageImperative(targetMessageId, { highlight: true });
handledTargetKeyRef.current = `${targetMessageId}:${highlightTarget}`;
scrollToMessageImperative(targetMessageId, { highlight: highlightTarget });
onTargetReached?.(targetMessageId);
}, [
highlightTarget,
isLoading,
messages,
onTargetReached,
Expand Down
66 changes: 66 additions & 0 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,72 @@ test("message links reopen a closed thread when the same messageId is already in
);
});

test("thread reply deep links highlight the targeted reply", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

const rootText = `Thread deep-link root ${Date.now()}`;
await page.getByTestId("message-input").fill(rootText);
await page.getByTestId("send-message").click();

const rootMessage = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.filter({ hasText: rootText });
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();

const threadPanel = page.getByTestId("message-thread-panel");
const parentReplyText = `Parent reply ${Date.now()}`;
const threadInput = threadPanel.getByTestId("message-input");
await threadInput.fill(parentReplyText);
await page.keyboard.press("Enter");

const parentReply = threadPanel
.getByTestId("message-row")
.filter({ hasText: parentReplyText });
await expect(parentReply).toBeVisible();
await parentReply.hover();
await parentReply.getByRole("button", { name: "Reply" }).click();

const nestedReplyText = `Deep-linked nested reply ${Date.now()}`;
await threadInput.fill(nestedReplyText);
await page.keyboard.press("Enter");

const nestedReply = threadPanel
.getByTestId("message-row")
.filter({ hasText: nestedReplyText });
await expect(nestedReply).toBeVisible();
const nestedReplyId = await nestedReply.getAttribute("data-message-id");
expect(nestedReplyId).toBeTruthy();
await expect
.poll(() =>
nestedReply.evaluate(
(element) => getComputedStyle(element, "::before").animationName,
),
)
.not.toContain("route-target-highlight-fade");

await page.goto(
`/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=${encodeURIComponent(nestedReplyId ?? "")}`,
);

const targetedReply = page
.getByTestId("message-thread-panel")
.locator(`[data-message-id="${nestedReplyId}"]`);
await expect(targetedReply).toBeInViewport();
await expect
.poll(() =>
targetedReply.evaluate(
(element) => getComputedStyle(element, "::before").animationName,
),
)
.toContain("route-target-highlight-fade");
});

test("message deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`,
Expand Down