diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f7cd01b781..e5d469d5d7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -140,6 +140,7 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages, threadPanelWidthPx, threadScrollTargetId, + threadScrollTargetHighlighted, threadTypingPubkeys, threadReplyTargetMessage, threadUnreadCounts, @@ -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} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index d5deeea100..e6ded66054 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -141,6 +141,7 @@ export type ChannelPaneProps = { threadTypingPubkeys: string[]; threadReplyTargetMessage: TimelineMessage | null; threadScrollTargetId: string | null; + threadScrollTargetHighlighted: boolean; threadUnreadCounts?: ReadonlyMap; threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ac3eded07e..765dfe7118 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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); @@ -667,6 +673,7 @@ export function ChannelScreen({ ); const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); + setThreadHighlightTargetId(null); }, []); const handleTargetReached = React.useCallback(() => { clearMessageRouteTarget({ replace: true }); @@ -683,6 +690,7 @@ export function ChannelScreen({ setOpenThreadHeadId, setProfilePanelPubkey, setThreadReplyTargetId, + setThreadHighlightTargetId, setThreadScrollTargetId, targetMessageId, timelineMessages, @@ -967,6 +975,7 @@ export function ChannelScreen({ threadTypingPubkeys={threadTypingPubkeys} threadReplyTargetMessage={displayedThreadReplyTargetMessage} threadScrollTargetId={threadScrollTargetId} + threadScrollTargetHighlighted={threadScrollTargetHighlighted} threadUnreadCounts={threadUnreadCounts} threadReplyUnreadCounts={threadReplyUnreadCounts} threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d6..a6636b3c18 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -61,6 +61,7 @@ export function useChannelRouteTarget({ setOpenThreadHeadId, setProfilePanelPubkey, setThreadReplyTargetId, + setThreadHighlightTargetId, setThreadScrollTargetId, targetMessageId, timelineMessages, @@ -73,6 +74,9 @@ export function useChannelRouteTarget({ setOpenThreadHeadId: PanelValueSetter; setProfilePanelPubkey: PanelValueSetter; setThreadReplyTargetId: React.Dispatch>; + setThreadHighlightTargetId: React.Dispatch< + React.SetStateAction + >; setThreadScrollTargetId: React.Dispatch>; targetMessageId: string | null; timelineMessages: TimelineMessage[]; @@ -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; @@ -161,6 +166,7 @@ export function useChannelRouteTarget({ setOpenThreadHeadId, setProfilePanelPubkey, setThreadReplyTargetId, + setThreadHighlightTargetId, setThreadScrollTargetId, targetMessageId, timelineMessageById, diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 391e3a0919..cdc6cc1749 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -86,6 +86,7 @@ type MessageThreadPanelProps = { profiles?: UserProfileLookup; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; + scrollTargetHighlighted?: boolean; threadHead: TimelineMessage | null; threadReplies: MainTimelineEntry[]; threadUnreadCount?: number; @@ -329,6 +330,7 @@ export function MessageThreadPanel({ profiles, replyTargetMessage, scrollTargetId, + scrollTargetHighlighted = false, threadHead, threadHeadVideoReviewContext, threadReplies, @@ -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; @@ -748,6 +756,7 @@ export function MessageThreadPanel({ isDirectChildOfHighlightedBranch } highlightThreadLineDepths={highlightedLineDepths} + highlighted={entry.message.id === highlightedMessageId} hoverBackground={!entry.summary} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7f..f2143c0a70 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -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; }; @@ -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 @@ -166,7 +169,7 @@ export function useAnchoredScroll({ const prevFirstMessageIdRef = React.useRef(undefined); const prevMessageCountRef = React.useRef(0); const prevMessagesRef = React.useRef>([]); - const handledTargetIdRef = React.useRef(null); + const handledTargetKeyRef = React.useRef(null); const highlightTimeoutRef = React.useRef(null); // Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled // on channel switch (the channelId reset effect clears it). @@ -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) { @@ -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(); @@ -442,6 +449,7 @@ export function useAnchoredScroll({ prevMessageCountRef.current = messages.length; prevMessagesRef.current = messages; }, [ + highlightTarget, isLoading, messages, onTargetReached, @@ -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; @@ -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, diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 57d8c5f3b2..0234130be5 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -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`,