diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx
index d172dd057c..76c2310d67 100644
--- a/src/bundled/channels/ChannelsPage.tsx
+++ b/src/bundled/channels/ChannelsPage.tsx
@@ -24,6 +24,7 @@ import { Avatar } from "../../shared/design-system/ui/Avatar";
import { useToastNotification } from "../../shared/design-system/ui/Toast";
import { useReturnHighlight } from "../../features/messages/use-return-highlight";
import { useMessageOpening } from "../../features/messages/use-message-opening";
+import { watchRevealRequest } from "../../features/messages/use-message-reveal";
import { ChannelBody } from "./ChannelBody";
import {
entryPane,
@@ -677,6 +678,12 @@ function ChannelWorkspace({
navigation?.target.kind === "conversation"
? navigation.target.threadRootId
: undefined;
+ useLayoutEffect(() => {
+ // The message's reveal waits for its channel, timeline or thread to load.
+ // Focus the person moves or text they type meanwhile counts from now.
+ if (!requestedMessage || !navigation || navigation.signal.aborted) return;
+ return watchRevealRequest(navigation.signal);
+ }, [requestedMessage, navigation]);
const membersRequested =
navigation?.target.kind === "conversation" &&
navigation.target.panel === "members";
diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx
index 9a1f5c0530..f46e763f5a 100644
--- a/src/features/messages/ThreadPanel.test.tsx
+++ b/src/features/messages/ThreadPanel.test.tsx
@@ -116,6 +116,7 @@ vi.mock("./MessageComposer", () => ({
data-placeholder={placeholder}
data-focus-request={focusRequest}
>
+
@@ -2043,6 +2044,87 @@ it("a retained reader reveals its exact selected message, again for each new req
expect(revealedReply()).toBe(false);
});
+it.each([
+ ["route", "after"],
+ ["route", "before"],
+ ["reveal request", "after"],
+ ["reveal request", "before"],
+] as const)(
+ "a %s into the open thread leaves focus in the editor the person typed into while it retargets (focused %s the request)",
+ async (kind, focused) => {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(
+ new DOMRect(0, 0, 500, 500),
+ );
+ const h = messagesHarness();
+ const reply = { ...row, id: "d".repeat(64), content: "the reply" };
+ h.snapshot.replies = [reply];
+ h.snapshot.target = reply;
+ h.snapshot.targetStatus = "ready";
+ const props = h.props as ThreadPanelProps;
+ Object.assign(props, { retainThread: true });
+ h.render();
+ const editor = screen.getByRole("textbox", { name: "Reply" });
+ // A click into the editor, with the browser's own focus move.
+ const enter = () => {
+ fireEvent.pointerDown(editor);
+ editor.focus();
+ fireEvent.pointerUp(editor);
+ fireEvent.click(editor);
+ };
+ // The person is already writing in the editor as the request arrives.
+ if (focused === "before") act(enter);
+ let release!: (value: "selected") => void;
+ h.view.retarget.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+ const navigation = { ...ordinaryNavigation(), entryId: "reply" };
+ navigation.target.messageId = reply.id;
+ Object.assign(
+ props,
+ { messageId: reply.id },
+ kind === "route"
+ ? { navigation }
+ : { revealSelected: true, revealRequest: 1 },
+ );
+ h.rerenderProps();
+ expect(h.view.retarget).toHaveBeenCalledOnce();
+ // The editor stays usable during the read: the person clicks into it and
+ // types, or keeps typing.
+ act(() => {
+ if (focused === "after") enter();
+ fireEvent.keyDown(editor, { key: "a" });
+ fireEvent(
+ editor,
+ new InputEvent("beforeinput", {
+ bubbles: true,
+ cancelable: true,
+ inputType: "insertText",
+ data: "a",
+ }),
+ );
+ });
+ await act(async () => release("selected"));
+ await waitFor(() =>
+ expect(
+ vi
+ .mocked(HTMLElement.prototype.scrollIntoView)
+ .mock.contexts.some(
+ (context) =>
+ (context as HTMLElement).dataset.messageId === reply.id,
+ ),
+ ).toBe(true),
+ );
+ if (kind === "route")
+ await waitFor(() =>
+ expect(navigation.complete).toHaveBeenCalledWith({ status: "opened" }),
+ );
+ expect(editor).toHaveFocus();
+ },
+);
+
it("reopens collapsed activity for a new reveal of the same step", async () => {
const h = messagesHarness();
const replies = ["d", "e"].map((id, index) => ({
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index f210954af5..eff7b8c9ff 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -58,7 +58,11 @@ import { useListedChannel } from "../relay/listed-channel";
import type { CSSProperties } from "react";
import styles from "./Messages.module.css";
import { Reading, readingPositioned } from "./use-reading";
-import { useMessageReveal } from "./use-message-reveal";
+import {
+ useMessageReveal,
+ watchArrival,
+ type RevealArrival,
+} from "./use-message-reveal";
import { usePendingRequest } from "./use-pending-request";
import type { PageNavigation } from "../navigation/service";
import { messageViewKey } from "./view-key";
@@ -229,6 +233,8 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
navigation: PageNavigation | undefined;
revealRequest: number | undefined;
revision: number;
+ /** The person's input since a request confirmed in place arrived. */
+ arrival?: RevealArrival | undefined;
};
const current = useRef(undefined);
const [opening, setOpening] = useState();
@@ -272,6 +278,7 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
const signal = navigation
? AbortSignal.any([request.signal, navigation.signal])
: request.signal;
+ let stopArrival: (() => void) | undefined;
if (!retrying.current) {
setError(undefined);
setFailure(undefined);
@@ -298,6 +305,10 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
}
if (prior?.view.snapshot().root) {
if (navigation) setRouting(navigation);
+ // The editor stays usable while the read runs, so focus the person
+ // moves or text they type meanwhile belongs to this request's reveal.
+ const arrival = { input: false };
+ stopArrival = watchArrival(arrival);
let result: ThreadRetarget;
try {
result = await prior.view.retarget(messageId, signal);
@@ -308,7 +319,13 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
}
if (signal.aborted || current.current !== prior) return;
if (result === "selected") {
- const next = { ...prior, messageId, navigation, revealRequest };
+ const next = {
+ ...prior,
+ messageId,
+ navigation,
+ revealRequest,
+ arrival,
+ };
current.current = next;
setOpening(next);
settle();
@@ -366,7 +383,10 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
}
}
void select();
- return () => request.abort();
+ return () => {
+ request.abort();
+ stopArrival?.();
+ };
}, [
session,
channelId,
@@ -395,6 +415,7 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
navigation={opening.navigation}
routePending={!!routing}
revealRequest={opening.revealRequest}
+ arrival={opening.arrival}
selectionFailure={
failure && { ...failure, pending, retry: retryNavigation }
}
@@ -536,8 +557,11 @@ function ThreadMessages({
selectionFailure,
onResolvedRoot,
routePending,
+ arrival,
}: {
onResolvedRoot?: ThreadPanelProps["onResolvedRoot"];
+ /** The person's input since the request retargeted in place arrived. */
+ arrival?: RevealArrival | undefined;
/** A route to this reader is on its way: until it lands, a reader not yet
* shown stays hidden and unread rather than showing a place the route
* replaces. */
@@ -960,6 +984,7 @@ function ThreadMessages({
snapshot.targetStatus === "ready" && snapshot.target?.id === messageId,
complete: completeTarget,
prepare: prepareTarget,
+ arrival,
});
// Leaving a visit once it was presented, by navigating on, hiding or
// unmounting, records where the reader was. A return counts only once
diff --git a/src/features/messages/use-message-reveal.test.tsx b/src/features/messages/use-message-reveal.test.tsx
index 883c43f56d..0fdd0509ac 100644
--- a/src/features/messages/use-message-reveal.test.tsx
+++ b/src/features/messages/use-message-reveal.test.tsx
@@ -1,10 +1,21 @@
// @vitest-environment jsdom
-import { act, cleanup, render, screen } from "@testing-library/react";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
import { useRef } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import styles from "./Messages.module.css";
import { HIGHLIGHT_MAX_WAIT_MS, HIGHLIGHT_QUIET_MS } from "./message-highlight";
-import { useMessageReveal } from "./use-message-reveal";
+import {
+ useMessageReveal,
+ watchArrival,
+ watchRevealRequest,
+ type RevealArrival,
+} from "./use-message-reveal";
// Control frame/mutation ordering; browser journeys own real layout and inert focus.
const frames = new Map();
@@ -56,20 +67,25 @@ async function idle() {
}
function mount(
hold?: () => boolean,
- { focus, mark }: { focus?: boolean; mark?: boolean } = {},
+ {
+ focus,
+ mark,
+ request = new AbortController(),
+ }: { focus?: boolean; mark?: boolean; request?: AbortController } = {},
) {
- const request = new AbortController();
const complete = vi.fn();
function Surface({
signal = request.signal,
prepare = undefined,
rowKey = 0,
messageId = "target",
+ arrival = undefined,
}: {
signal?: AbortSignal;
prepare?: () => void;
rowKey?: number;
messageId?: string;
+ arrival?: RevealArrival;
}) {
const scroller = useRef(null);
const settled = useRef(false);
@@ -84,6 +100,7 @@ function mount(
...(mark === undefined ? {} : { mark }),
...(prepare ? { prepare } : {}),
...(hold ? { hold } : {}),
+ ...(arrival ? { arrival } : {}),
});
return (
<>
@@ -98,22 +115,30 @@ function mount(
+
>
);
}
const result = render();
const row = screen.getByText("Target");
const outside = screen.getByRole("button", { name: "Elsewhere" });
+ const composer = screen.getByRole("textbox", { name: "Composer" });
return {
...result,
request,
complete,
row,
outside,
+ composer,
restart: () => result.rerender( {}} />),
remountRow: () => result.rerender(),
- next: () =>
- result.rerender(),
+ next: (arrival?: RevealArrival) =>
+ result.rerender(
+ ,
+ ),
other: () =>
result.rerender(
,
@@ -224,6 +249,364 @@ it("marks a reveal beside the reader only once its row is uncovered", async () =
expect(document.activeElement).toBe(outside);
});
+it("focuses the revealed row when nothing claimed focus since the request", async () => {
+ const { row, complete } = mount();
+ await frame(); // Attach observers.
+ await frame(); // Reveal.
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+// The browser's own focus move for each press, which jsdom does not make.
+const moves = {
+ click(composer: HTMLElement) {
+ fireEvent.pointerDown(composer);
+ composer.focus();
+ fireEvent.pointerUp(composer);
+ fireEvent.click(composer);
+ },
+ tap(composer: HTMLElement) {
+ fireEvent.pointerDown(composer);
+ fireEvent.pointerUp(composer);
+ composer.focus();
+ fireEvent.click(composer);
+ },
+ Tab(composer: HTMLElement) {
+ fireEvent.keyDown(document.body, { key: "Tab" });
+ composer.focus();
+ fireEvent.keyUp(composer, { key: "Tab" });
+ },
+ "Shift+Tab"(composer: HTMLElement) {
+ fireEvent.keyDown(document.body, { key: "Shift", shiftKey: true });
+ fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true });
+ composer.focus();
+ fireEvent.keyUp(composer, { key: "Tab", shiftKey: true });
+ fireEvent.keyUp(composer, { key: "Shift" });
+ },
+};
+
+it.each(Object.keys(moves) as (keyof typeof moves)[])(
+ "leaves focus with a composer the person moved to before the reveal lands (%s)",
+ async (input) => {
+ const { row, composer, complete } = mount();
+ const focus = vi.spyOn(row, "focus");
+ await frame(); // Attach observers; the target is still loading.
+ moves[input](composer);
+ await frame(); // Reveal.
+ expect(row.scrollIntoView).toHaveBeenCalled();
+ expect(document.activeElement).toBe(composer);
+ await mutate(row); // Another reveal attempt still leaves the composer.
+ await frame();
+ await frame();
+ expect(document.activeElement).toBe(composer);
+ expect(focus).not.toHaveBeenCalled();
+ expect(complete).toHaveBeenCalledOnce();
+ await idle();
+ expect(highlighted(row)).toBe(true);
+ },
+);
+
+it("takes focus the app handed to another control without the person's input", async () => {
+ const { row, composer, complete } = mount();
+ await frame();
+ composer.focus(); // Such as a closing dialog's focus return.
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+it.each(["Shift", "ArrowDown", "Escape"])(
+ "takes focus the app handed off when a key moved nothing (%s)",
+ async (key) => {
+ const { row, composer, complete } = mount();
+ await frame();
+ composer.focus();
+ fireEvent.keyDown(composer, { key });
+ fireEvent.keyUp(composer, { key });
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+ },
+);
+
+it.each([false, true])(
+ "takes focus an app handler moved in answer to a key other than Tab (later: %s)",
+ async (later) => {
+ const { row, outside, composer, complete } = mount();
+ await frame();
+ outside.focus(); // Such as a dialog's close button.
+ // A dialog closing on Escape returns focus at once or, as Base UI does,
+ // in a later task.
+ outside.addEventListener("keydown", () =>
+ later ? setTimeout(() => composer.focus()) : composer.focus(),
+ );
+ fireEvent.keyDown(outside, { key: "Escape" });
+ fireEvent.keyUp(document.activeElement ?? document.body, {
+ key: "Escape",
+ });
+ await act(() => new Promise((resolve) => setTimeout(resolve)));
+ expect(document.activeElement).toBe(composer);
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+ },
+);
+
+it("takes focus an app handler moved in answer to a press on another control", async () => {
+ const { row, outside, composer, complete } = mount();
+ await frame();
+ outside.addEventListener("pointerdown", () => composer.focus());
+ fireEvent.pointerDown(outside);
+ fireEvent.pointerUp(outside);
+ fireEvent.click(outside);
+ expect(document.activeElement).toBe(composer);
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+it("takes focus the app handed off after a key that moved nothing", async () => {
+ const { row, composer, complete } = mount();
+ await frame();
+ fireEvent.keyDown(document.body, { key: "Shift" });
+ fireEvent.keyUp(document.body, { key: "Shift" });
+ composer.focus();
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+// A click inside a composer that already has focus moves nothing, and the
+// reveal takes focus as it would without the click unless the person types.
+it("takes focus from a composer the app focused when the person only clicks in it", async () => {
+ const { row, composer, complete } = mount();
+ await frame();
+ composer.focus();
+ fireEvent.pointerDown(composer);
+ fireEvent.pointerUp(composer);
+ fireEvent.click(composer);
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+it.each([
+ [
+ "click",
+ (composer: HTMLElement) => {
+ composer.focus();
+ fireEvent.pointerDown(composer);
+ fireEvent.pointerUp(composer);
+ fireEvent.click(composer);
+ composer.blur();
+ },
+ ],
+ [
+ "Tab",
+ () => {
+ fireEvent.keyDown(document.body, { key: "Tab" });
+ fireEvent.keyUp(document.body, { key: "Tab" });
+ },
+ ],
+] as const)(
+ "does not count a %s that ended without moving focus as a later claim",
+ async (_, press) => {
+ const { row, composer, complete } = mount();
+ await frame();
+ press(composer);
+ composer.focus(); // A later hand-off by the app.
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+ },
+);
+
+// A press with another button ends without a click, so focus the app later
+// returns to the pressed control, such as a context menu closing, is not the
+// person's.
+it.each([
+ [
+ "contextmenu",
+ (composer: HTMLElement) => {
+ fireEvent.pointerDown(composer, { button: 2 });
+ fireEvent.pointerUp(composer, { button: 2 });
+ fireEvent.contextMenu(composer, { button: 2 });
+ },
+ ],
+ [
+ "auxclick",
+ (composer: HTMLElement) => {
+ fireEvent.pointerDown(composer, { button: 1 });
+ fireEvent.pointerUp(composer, { button: 1 });
+ fireEvent(
+ composer,
+ new MouseEvent("auxclick", { bubbles: true, button: 1 }),
+ );
+ },
+ ],
+] as const)(
+ "does not count a press that ended at its %s as a later claim",
+ async (_, press) => {
+ const { row, outside, composer, complete } = mount();
+ await frame();
+ press(composer);
+ outside.focus(); // Such as the menu taking focus.
+ composer.focus(); // Its focus return as it closes.
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+ },
+);
+
+it.each(["Escape", "pointer"])(
+ "takes focus moved to the surface around the scroller (%s)",
+ async (input) => {
+ const { row, container, complete } = mount();
+ container.tabIndex = -1;
+ await frame();
+ if (input === "pointer") {
+ // The browser focuses the surface for a press on a row's text.
+ fireEvent.pointerDown(row);
+ container.focus();
+ fireEvent.pointerUp(row);
+ fireEvent.click(row);
+ } else {
+ fireEvent.keyDown(document.body, { key: input });
+ container.focus();
+ fireEvent.keyUp(container, { key: input });
+ }
+ expect(document.activeElement).toBe(container);
+ await frame();
+ expect(document.activeElement).toBe(row);
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+ },
+);
+
+it("leaves focus with a composer the app focused once the person types into it", async () => {
+ const { row, composer, complete } = mount();
+ const focus = vi.spyOn(row, "focus");
+ await frame();
+ composer.focus();
+ fireEvent.keyDown(composer, { key: "a" });
+ fireEvent(
+ composer,
+ new InputEvent("beforeinput", {
+ bubbles: true,
+ cancelable: true,
+ inputType: "insertText",
+ data: "a",
+ }),
+ );
+ await frame();
+ expect(row.scrollIntoView).toHaveBeenCalled();
+ expect(document.activeElement).toBe(composer);
+ expect(focus).not.toHaveBeenCalled();
+ await frame();
+ expect(complete).toHaveBeenCalledOnce();
+});
+
+// The editor the person was already writing in when a link retargeted the
+// open thread had focus as the request arrived. Their typing still claims it.
+it("leaves focus with the control focused as the request arrived once the person types into it", async () => {
+ const { row, composer, complete, next } = mount();
+ await idle();
+ expect(document.activeElement).toBe(row);
+ composer.focus();
+ next();
+ vi.mocked(row.scrollIntoView).mockClear();
+ const focus = vi.spyOn(row, "focus");
+ fireEvent.keyDown(composer, { key: "a" });
+ fireEvent(
+ composer,
+ new InputEvent("beforeinput", {
+ bubbles: true,
+ cancelable: true,
+ inputType: "insertText",
+ data: "a",
+ }),
+ );
+ await idle();
+ expect(row.scrollIntoView).toHaveBeenCalled();
+ expect(document.activeElement).toBe(composer);
+ expect(focus).not.toHaveBeenCalled();
+ expect(complete).toHaveBeenCalledTimes(2);
+ expect(highlighted(row)).toBe(true);
+});
+
+// Without input, the control focused as the request arrived is an opener the
+// person clicked and then waited on, and the row takes focus from it.
+it("takes focus from the control focused as the request arrived when the person does nothing", async () => {
+ const { row, composer, complete, next } = mount();
+ await idle();
+ expect(document.activeElement).toBe(row);
+ composer.focus();
+ next();
+ await idle();
+ expect(document.activeElement).toBe(row);
+ expect(complete).toHaveBeenCalledTimes(2);
+});
+
+// A thread retargeting in place records the arrival before the read and hands
+// it to the reveal that follows; the next request records its own.
+it("adopts an arrival recorded before the signal, for that request only", async () => {
+ const { row, composer, complete, next } = mount();
+ await idle();
+ expect(document.activeElement).toBe(row);
+ const arrival = { input: false };
+ const stop = watchArrival(arrival);
+ try {
+ moves.click(composer);
+ next(arrival);
+ await idle();
+ expect(document.activeElement).toBe(composer);
+ expect(complete).toHaveBeenCalledTimes(2);
+ next(arrival);
+ await idle();
+ expect(document.activeElement).toBe(row);
+ expect(complete).toHaveBeenCalledTimes(3);
+ } finally {
+ stop();
+ }
+});
+
+// A message visit's owner starts the record when it receives the visit; the
+// timeline that reveals it mounts only once the channel loads.
+it.each([
+ ["leaves focus moved before the reveal mounted, watched from receipt", true],
+ ["takes focus moved before the reveal mounted when nothing watched", false],
+])("%s", async (_, watched) => {
+ const request = new AbortController();
+ const composer = document.body.appendChild(
+ document.createElement("textarea"),
+ );
+ const stop = watched ? watchRevealRequest(request.signal) : () => {};
+ try {
+ moves.click(composer);
+ const { row, complete, next } = mount(undefined, { request });
+ await idle();
+ expect(row.scrollIntoView).toHaveBeenCalled();
+ expect(document.activeElement).toBe(watched ? composer : row);
+ expect(complete).toHaveBeenCalledOnce();
+ next(); // The record serves only its own request.
+ await idle();
+ expect(document.activeElement).toBe(row);
+ expect(complete).toHaveBeenCalledTimes(2);
+ } finally {
+ stop();
+ composer.remove();
+ }
+});
+
it.each([false, true])(
"preserves moved focus before verification (mutation: %s)",
async (mutation) => {
diff --git a/src/features/messages/use-message-reveal.ts b/src/features/messages/use-message-reveal.ts
index e8b8e1b832..97f95a341d 100644
--- a/src/features/messages/use-message-reveal.ts
+++ b/src/features/messages/use-message-reveal.ts
@@ -15,6 +15,7 @@ export function useMessageReveal({
hold,
focus = true,
mark = focus,
+ arrival,
}: {
scroller: RefObject;
settled: RefObject;
@@ -36,6 +37,12 @@ export function useMessageReveal({
* it out of view. The owner ends the hold on reader intent.
*/
hold?: () => boolean;
+ /**
+ * The person's input since the request reached an owner that forwards its
+ * signal only after confirming the target, such as a thread retargeting in
+ * place.
+ */
+ arrival?: RevealArrival | undefined;
}) {
const revealed = useRef(undefined);
const focused = useRef<{ signal: AbortSignal; row: HTMLElement } | undefined>(
@@ -53,6 +60,26 @@ export function useMessageReveal({
},
[],
);
+ // The person's input since each request arrived, watched from before the
+ // target loads, however late. A record started for the signal when its owner
+ // received it comes first. An arrival handed in by the owner serves only the
+ // first new signal after it.
+ const arrived = useRef<{ signal: AbortSignal; arrival: RevealArrival }>(
+ undefined,
+ );
+ const adopted = useRef(undefined);
+ useLayoutEffect(() => {
+ if (!signal) return;
+ if (arrived.current?.signal !== signal) {
+ const handed = arrival !== adopted.current ? arrival : undefined;
+ if (handed) adopted.current = handed;
+ arrived.current = {
+ signal,
+ arrival: received.get(signal) ?? handed ?? { input: false },
+ };
+ }
+ return watchArrival(arrived.current.arrival);
+ }, [signal, arrival]);
useLayoutEffect(() => {
if (
!ready ||
@@ -91,15 +118,27 @@ export function useMessageReveal({
});
placed?.();
settled.current = true;
- // A replacement may recover lost focus, but must not take it from another
- // control after this request already focused a row successfully.
+ // The first focus yields to the focused control once the person has
+ // moved focus or typed since the request arrived, so their typing stays
+ // there: the composer of a still-loading conversation, or the editor
+ // they were already writing in when a link retargeted the open thread.
+ // Focus the app handed off without such input, or left on the control
+ // that opened the request or the surface around the scroller, is still
+ // taken.
+ // A replacement may recover lost focus, but must not take it from
+ // another control after this request already focused a row successfully.
+ const active = document.activeElement;
+ const lost = !active || active === document.body;
+ const claimed =
+ !lost &&
+ !active.contains(container) &&
+ !!arrived.current?.arrival.input;
let focusPending = false;
if (
focus &&
- (focused.current?.signal !== signal ||
- (focused.current.row !== row &&
- (!document.activeElement ||
- document.activeElement === document.body)))
+ (focused.current?.signal !== signal
+ ? !claimed
+ : focused.current.row !== row && lost)
) {
row.focus({ preventScroll: true });
focusPending = document.activeElement !== row;
@@ -182,6 +221,82 @@ export function useMessageReveal({
return revealed;
}
+/**
+ * Whether the person has moved focus or typed into the focused control since a
+ * reveal request arrived.
+ */
+export type RevealArrival = { input: boolean };
+
+const received = new WeakMap();
+
+/**
+ * Until stopped, watch the person's input for a request from when its owner
+ * receives it, for a reveal that mounts only once its target's surface loads,
+ * such as a message visit into a channel still loading. The reveal adopts this
+ * record when it receives the signal.
+ */
+export function watchRevealRequest(signal: AbortSignal): () => void {
+ let arrival = received.get(signal);
+ if (!arrival) {
+ arrival = { input: false };
+ received.set(signal, arrival);
+ }
+ return watchArrival(arrival);
+}
+
+/**
+ * Until stopped, set `input` when the person moves focus or types into the
+ * focused control. Only a move the browser makes for a press counts: focus
+ * landing on or around the pointer's target before its click, or Tab. Focus an
+ * app handler moves in answer to another key or a click, such as a dialog
+ * closing on Escape, is not the person's.
+ */
+export function watchArrival(arrival: RevealArrival): () => void {
+ // A press is pending until it ends. A tap focuses after pointerup, so a
+ // pointer press ends at its click, or, for another button, at its
+ // contextmenu or auxclick.
+ let pointer: Node | null = null;
+ let tab = false;
+ const pressed = (event: Event) => {
+ pointer = event.target instanceof Node ? event.target : null;
+ };
+ const released = () => {
+ pointer = null;
+ };
+ const keyed = (event: Event) => {
+ const { key, altKey, ctrlKey, metaKey } = event as KeyboardEvent;
+ tab = key === "Tab" && !altKey && !ctrlKey && !metaKey;
+ };
+ const unkeyed = () => {
+ tab = false;
+ };
+ const moved = (event: Event) => {
+ const target = event.target;
+ if (tab || (pointer && target instanceof Node && target.contains(pointer)))
+ arrival.input = true;
+ };
+ const typed = (event: Event) => {
+ if (event.target === document.activeElement) arrival.input = true;
+ };
+ const listeners = [
+ ["pointerdown", pressed],
+ ["click", released],
+ ["contextmenu", released],
+ ["auxclick", released],
+ ["pointercancel", released],
+ ["keydown", keyed],
+ ["keyup", unkeyed],
+ ["focusin", moved],
+ ["beforeinput", typed],
+ ] as const;
+ for (const [type, listener] of listeners)
+ document.addEventListener(type, listener, true);
+ return () => {
+ for (const [type, listener] of listeners)
+ document.removeEventListener(type, listener, true);
+ };
+}
+
/** Undo layout drift of a revealed row until its owner, request or viewport ends the hold. */
function keep(
container: HTMLElement,
diff --git a/tests/browser/message-navigation.spec.mjs b/tests/browser/message-navigation.spec.mjs
index 05b7db377a..64037239dd 100644
--- a/tests/browser/message-navigation.spec.mjs
+++ b/tests/browser/message-navigation.spec.mjs
@@ -536,6 +536,55 @@ test("superseding a held exact read cancels it; a late response cannot steal foc
await expect.poll(() => status(page)).toBe("opened");
});
+test.describe("cold channel", () => {
+ test.use({ historyCounts: { alpha: 103, beta: 1 } });
+ // Browser-only: the timeline mounts after the channel's head read, so only a
+ // real load shows that a click made meanwhile keeps its focus.
+ test("an exact message opened into a loading channel keeps focus the person moved meanwhile", async ({
+ page,
+ app,
+ }) => {
+ const beta = Promise.withResolvers();
+ await page.route("**/query", async (route) => {
+ const filters = route.request().postDataJSON() ?? [];
+ if (filters.some((filter) => filter["#h"]?.includes("beta")))
+ await beta.promise;
+ await route.fallback();
+ });
+ const [message] = app.histories.get("primary/beta");
+ const composer = page.getByRole("textbox", {
+ name: "Message #Beta",
+ exact: true,
+ });
+ try {
+ await open(page, app);
+ await page.evaluate(
+ (value) => {
+ window.fixtureNavigation.open(value);
+ },
+ {
+ ...target(app, message.id),
+ channelId: "beta",
+ threadRootId: undefined,
+ },
+ );
+ await expect(
+ page.getByRole("status").filter({ hasText: "Loading messages…" }),
+ ).toBeVisible();
+ await composer.click();
+ } finally {
+ beta.resolve();
+ }
+ await expect.poll(() => status(page)).toBe("opened");
+ await expect(
+ page.locator(
+ `[data-channel-timeline="beta"] [data-message-id="${message.id}"]`,
+ ),
+ ).toBeInViewport();
+ await expect(composer).toBeFocused();
+ });
+});
+
for (const selected of ["reply", "root"]) {
test(`same-scope replacement waits for fresh membership before reopening the exact ${selected}`, async ({
page,