Skip to content
16 changes: 12 additions & 4 deletions docs/unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ visible rows, and 300 ms dwell. Scroll/content/focus changes cancel/restart dwel
scheduler when positioning finishes, even if an empty history page changes no
rows or geometry.
The list and its own composer share a reading surface; focus anywhere in an open
thread panel also qualifies, including its selected owning tab header. Explicitly returning to a channel resumes reading in its visible selected thread for that channel; a hidden pane or another channel’s tab keeps the main conversation’s focus. Pane motion completion restarts observation after unchanged rows enter the viewport. Inactive
or hidden tab content and passive restoration do not qualify. Another pane, a
dialog, background window, preload,
and mounted virtualizer overscan do not qualify.
thread panel also qualifies, including its selected owning tab header. Explicitly
returning to a channel reads both its main conversation and its visible selected
side-pane thread tab for that channel: while focus stays anywhere in that
channel's workspace (normally the main composer), that side-pane thread
qualifies too. A thread in the main pane never borrows this grant. A link to
a message or thread (Activity, a message link) is not a plain visit and reads
only what holds focus. A hidden or collapsed pane, another channel's tab, a
members or media-review view, and a passive restoration (`preserve` focus) keep
the main conversation as the only reading surface. Inactive or hidden tab
content does not qualify. Pane motion completion restarts observation after
unchanged rows enter the viewport. Another pane, a dialog, background window,
preload, and mounted virtualizer overscan do not qualify.

Away from the bottom, observations mark individual fully visible messages. At the
physical bottom, 300 ms visibility of the newest message's bottom edge also earns
Expand Down
48 changes: 28 additions & 20 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1304,11 +1304,14 @@ function ChannelWorkspace({
const hasPanel = companion || hasChannelPanel;
const showingChannelPanel = tabState.paneOpen && hasChannelPanel;
const showingPanel = companion || showingChannelPanel;
// An explicit channel opening resumes its visible selected thread. Passive
// restoration and another channel’s retained tab keep their existing focus.
// A plain channel visit reads its visible selected thread along with the
// conversation that holds focus. A link to a message or thread, passive
// restoration and another channel’s retained tab do not.
const readingThread =
showingChannelPanel &&
!companion &&
!requestedMessage &&
!requestedThread &&
!membersOpen &&
!showingMediaReview &&
(navigation?.focus ?? navigator?.snapshot().attempt.focus) !== "preserve" &&
Expand All @@ -1319,28 +1322,33 @@ function ChannelWorkspace({
tab.channelId === currentId,
);
const split = usePanelSplit();
const readingVisit = useRef<AbortSignal | string | undefined>(undefined);
// A routed thread can mount behind a collapsed pane, where it cannot take
// focus, or already be selected, so its pane does not move focus. Once its
// tab shows, hand it focus nothing else took.
const routedTab =
routedThread && !membersOpen
? tabState.tabs.find(
(tab) =>
tab.kind === "thread" &&
tab.channelId === routedThread.channelId &&
tab.messageId === routedThread.messageId &&
(tabPane(tab) === "main"
? tabState.mainSelected === tab.id
: showingChannelPanel && !companion && selectedTab === tab.id),
)?.id
: undefined;
const routedVisit = useRef<AbortSignal | string | undefined>(undefined);
useLayoutEffect(() => {
const visit = navigation?.signal ?? currentId;
if (!readingThread || readingVisit.current === visit) return;
readingVisit.current = visit;
const focused = document.activeElement;
// Only hand off the channel opener. A person who moved focus while the
// retained pane mounted keeps their new target.
if (
focused !== document.body &&
(!(focused instanceof Element) ||
focused
.closest("[data-channel-id]")
?.getAttribute("data-channel-id") !== currentId)
)
return;
if (!routedTab || routedVisit.current === visit) return;
routedVisit.current = visit;
if (document.activeElement !== document.body) return;
split.ref.current
?.querySelector<HTMLElement>(
`[role="tab"][data-tab-value="${CSS.escape(selectedTab)}"]`,
`[role="tab"][data-tab-value="${CSS.escape(routedTab)}"]`,
)
?.focus({ preventScroll: true });
}, [readingThread, navigation?.signal, currentId, selectedTab, split.ref]);
}, [routedTab, navigation?.signal, currentId, split.ref]);
useEffect(() => {
// Only an explicit settings click moves focus; restoring a visit does not.
if (settingsFocus)
Expand Down Expand Up @@ -1514,8 +1522,7 @@ function ChannelWorkspace({
!current.readOnly &&
!requestedMessage &&
!requestedThread &&
!routedThread &&
!readingThread
!routedThread
}
trailingTool={
solo ? (
Expand All @@ -1535,6 +1542,7 @@ function ChannelWorkspace({
const workspace = (
<div
data-channel-workspace={currentId ?? ""}
data-reading-thread={readingThread ? "" : undefined}
ref={split.ref}
style={split.style}
className={`${styles.board} ${panelStyles.splitMotion}`}
Expand Down
35 changes: 35 additions & 0 deletions src/features/messages/use-reading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,41 @@ it("only the owning selected tab earns dwell; inactive and restored content does
vi.advanceTimersByTime(1);
expect(movedLease?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]);
});
it("a panel in a reading-thread workspace reads while focus is elsewhere in that workspace", () => {
const h = setup({ focus: "outside" });
const workspace = document.createElement("div");
const side = document.createElement("div");
side.setAttribute("data-panel-workspace", "");
const surface = h.element.closest("aside");
const unrelated = document.createElement("button");
expect(surface).not.toBeNull();
document.body.append(workspace, unrelated);
if (surface) side.append(surface);
workspace.append(side, h.outside);
h.retarget("thread-room");
h.outside.focus();
vi.advanceTimersByTime(300);
expect(h.reading).not.toHaveBeenCalled();
workspace.setAttribute("data-reading-thread", "");
h.retarget("thread-room-again");
Comment thread
johnmatthewtennant marked this conversation as resolved.
h.outside.focus();
vi.advanceTimersByTime(299);
expect(h.leases.at(-1)?.observe).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(h.dwelled()?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]);
const count = h.reading.mock.calls.length;
unrelated.focus();
vi.advanceTimersByTime(300);
expect(h.reading).toHaveBeenCalledTimes(count);
// A thread in the main pane belongs to no side-pane selection: the same
// workspace focus does not read it.
side.setAttribute("data-main-workspace", "");
side.removeAttribute("data-panel-workspace");
h.retarget("thread-room-main");
h.outside.focus();
vi.advanceTimersByTime(300);
expect(h.reading).toHaveBeenCalledTimes(count);
});
it("focus moving from the list to its composer keeps reading; another surface's composer cancels it", () => {
const h = setup();
vi.advanceTimersByTime(100);
Expand Down
6 changes: 6 additions & 0 deletions src/features/messages/use-reading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export function useReading({
(element.contains(node) ||
!!surface?.contains(node) ||
focusedTab(node) ||
// A plain channel visit also reads the side pane's visible selected
// thread while focus stays anywhere in that channel's workspace.
!!surface
?.closest("[data-panel-workspace]")
?.closest("[data-reading-thread]")
?.contains(node) ||
!!composer?.current?.contains(node));
// Everything but geometry, so an event can check it without a layout.
const ready = () =>
Expand Down
6 changes: 2 additions & 4 deletions tests/browser/channel-tabs.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,8 @@ test("content-opened tabs restore drafts without offering blank-tab creation", a
exact: true,
});
await expect(alphaComposer).toHaveText("Alpha draft");
// Returning to Alpha resumes reading in its visible selected thread.
await expect(
workspace.locator('[role="tab"][data-tab-value^="thread:alpha:"]'),
).toBeFocused();
// Restored side content must not take focus from the main conversation.
await expect(alphaComposer).toBeFocused();
await workspace.getByRole("tab", { name: "Beta", exact: true }).click();
await hoverMessage(row);
await row.getByRole("button", { name: "Reply", exact: true }).click();
Expand Down
Loading
Loading