Skip to content
7 changes: 7 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
82 changes: 82 additions & 0 deletions src/features/messages/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ vi.mock("./MessageComposer", () => ({
data-placeholder={placeholder}
data-focus-request={focusRequest}
>
<textarea aria-label="Reply" />
<button type="button" onClick={() => onSend?.("own-reply")}>
Send fixture reply
</button>
Expand Down Expand Up @@ -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) => ({
Expand Down
31 changes: 28 additions & 3 deletions src/features/messages/ThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Opening | undefined>(undefined);
const [opening, setOpening] = useState<Opening>();
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -366,7 +383,10 @@ function RetainedThreadPanel(props: ThreadPanelProps) {
}
}
void select();
return () => request.abort();
return () => {
request.abort();
stopArrival?.();
};
}, [
session,
channelId,
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading