diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 04df542872..fa947dfa8f 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -471,10 +471,14 @@ revokes all of them, and a pairing unused for 30 days expires. replies previews its newest unread reply and opens it in its thread. Muted conversations are left out. Turn this off with **Show unread first in search** in Settings → Appearance → Conversations. -- **Activity** threads name their channel above the original message. Select that - name, or **Open in channel** in the thread tab's menu, to open the thread in - Channels beside its original message, which the channel history shows and - highlights. +- **Find a tab’s source**: thread tabs show **In #channel-name** (or the chat + name) above their messages, including in the current channel. Select that name, + or **Show original message in channel** in the tab’s context menu, to reveal + and highlight the original message in the main timeline. Chat tabs use “chat” + in the action label. The thread tab stays available; Back restores the prior + view. Conversation tabs offer **Open channel/chat**, or **Show message in + channel/chat** when opened at a message. If the original cannot be found, + Buzz shows a failure with Retry. - **Sidebar activity**: rest the pointer on a sidebar row to list its unread mentions of you and its unread threads, mentions first. Open one or mark it read from the list; just looking marks nothing read. diff --git a/docs/inbox.md b/docs/inbox.md index f6aa9eaf34..b8a6e562be 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -36,7 +36,7 @@ cached/profile-backed identity evidence, never a new inventory or name heuristic The Mentions activity filter uses explicit mention messages for each conversation's preview, sender, ordering, resume point and unread count. A later ordinary reply does not make a read mention unread again. DMs remain direct activity rather than mentions merely because their recipient is tagged. All activity and Threads retain their conversation-wide projections. A row shows its newest message and sender, matching its time, while opening it still resumes at the oldest unread message. A Solo chat's top-level row shows the newest top-level message from anyone else, and is ordered by it. It skips messages addressed only to agents and roots that have their own thread row. Rows retain exact IDs and current names while `inboxFeed.incomplete` marks only specific group members awaiting their stored edit/deletion closure. Those rows say “Preview updating…” or “Preview unavailable. Retry activity.”; other rows stay usable. This is a visible completeness warning, not a guarantee that the relay did not change after verification. A failed read exposes Retry above the list. Retry stays focusable while pending; successful recovery returns focus to the filters only if Retry still owned focus when its alert was removed. -The list is the Inbox page's first tab, in the host tab workspace behind `conversation.ui.Tabs`. Opening a row shows its conversation beside the list in the side tab pane, with the same reader and composer as Channels tabs: a thread at its exact message, revealed even outside the newest bounded page, or a whole DM or session conversation where its Channels tab would open. A DM row opens as a thread at its resume message only when that thread holds all of the row's unread messages, or, with none unread, when it resumes at a reply. Otherwise it opens whole, where threads show their reply counts. A DM thread's reads, on the row's click and in its later dwells (including in a tab restored on returning to Activity), cover the DM only through the newest message this device has received, and stop just before its earliest unread message outside that thread (a top-level message or a reply in another thread). So a message outside the thread that was received before the read stays unread, even one received after the row last rendered. A DM's read state is one point in time, so the thread's replies from that message on stay unread too, until the whole DM is read; meanwhile the row opens the whole DM. A message outside the thread received only after a thread read, but dated at or before what that read covered (sender clock skew, a message sent in the same second as a thread reply, or an outbox retry that keeps its original time), is read without being shown. A whole DM opened from its row reads through the click time, as a channel's explicit read does. A plain open replaces the tab the previous one added, so one tab follows the selection; a tab moved to the main pane stays. The tab's right-click menu offers Open in channel, which opens its conversation in Channels; a thread opens there at the same message, beside its root, which the channel timeline reveals and marks even beyond its newest page. A thread also names its conversation above its original message, as the rows do (`#channel` or `DM · names`); that name is a link that does the same. The pane resizes, closes and handles Escape as Channels' does. Returning to Activity in the same session, through Back or the sidebar, restores the list's scroll position once it shows, unless it has already been scrolled, and brings the selected row back into view if it was visible on leaving and has since moved out of view; a relaunch starts at the top. A filter change keeps the list's current position: an empty list that the change fills stays at the top. A late verified root can regroup a conversation without changing its open tab. Inbox tabs do not enter a channel visit, so showing one preserves forced-unread intent. Reading is saved by the shared unread owner, through the reader's dwell and the row's explicit read. A Solo chat's top-level row reads in one save, as its live bottom does: the channel catch-up mark reads its ordinary responses, and mentions are read individually, so its thread rows stay unread. As in a plain channel visit, the visible selected side tab reads while focus stays anywhere on the page, such as on the row that opened it; a tab moved to the main pane reads only by its own focus. Unlike a restored Channels side tab, an Inbox tab restored on returning to Inbox also reads, since it is still the selected conversation. Failed read actions retain their captured Retry unless access, the session or a newer intent retires it. A DM Retry reuses the original channel cutoff and manual-clear keys, not the retry-time clock or later arrivals. Re-clicking the selected row reselects its tab without repeating its read. Closing the tab ends the selection, so reopening the row reads it again. Archiving the selected conversation's channel, or its room becoming a Huddle, closes its tab and returns focus it held to the filters, and restoring the channel does not reopen it; other retained tabs follow Channels' unavailable placeholder, and focus their reader held moves to the tab. Re-clicking the selected row reveals its current message in its tab. A whole DM or session tab stays whole when its row regroups or reads into a thread; a thread tab follows its row, so a DM thread whose row no longer opens as a thread opens the whole DM. Context menus allow device-local Mark unread. Row accessible descriptions reuse the visible safe preview or incomplete placeholder; Show more follows the filtered result count. `NavigationItem` owns selected styling and `aria-current`. No Inbox-owned session, parallel fold, composer or outbox. +The list is the Inbox page's first tab, in the host tab workspace behind `conversation.ui.Tabs`. Opening a row shows its conversation beside the list in the side tab pane, with the same reader and composer as Channels tabs: a thread at its exact message, revealed even outside the newest bounded page, or a whole DM or session conversation where its Channels tab would open. A DM row opens as a thread at its resume message only when that thread holds all of the row's unread messages, or, with none unread, when it resumes at a reply. Otherwise it opens whole, where threads show their reply counts. A DM thread's reads, on the row's click and in its later dwells (including in a tab restored on returning to Activity), cover the DM only through the newest message this device has received, and stop just before its earliest unread message outside that thread (a top-level message or a reply in another thread). So a message outside the thread that was received before the read stays unread, even one received after the row last rendered. A DM's read state is one point in time, so the thread's replies from that message on stay unread too, until the whole DM is read; meanwhile the row opens the whole DM. A message outside the thread received only after a thread read, but dated at or before what that read covered (sender clock skew, a message sent in the same second as a thread reply, or an outbox retry that keeps its original time), is read without being shown. A whole DM opened from its row reads through the click time, as a channel's explicit read does. A plain open replaces the tab the previous one added, so one tab follows the selection; a tab moved to the main pane stays. The tab's right-click menu offers Open channel/chat for whole conversations, or Show original message in channel/chat for threads. The thread action opens Channels and reveals its verified top-level source in the timeline, seeking beyond the newest page when needed; the original Activity tab stays available on return. A thread also names its conversation above its original message, as the rows do (`#channel` or `DM · names`); that name is a link that does the same. The pane resizes, closes and handles Escape as Channels' does. Returning to Activity in the same session, through Back or the sidebar, restores the list's scroll position once it shows, unless it has already been scrolled, and brings the selected row back into view if it was visible on leaving and has since moved out of view; a relaunch starts at the top. A filter change keeps the list's current position: an empty list that the change fills stays at the top. A late verified root can regroup a conversation without changing its open tab. Inbox tabs do not enter a channel visit, so showing one preserves forced-unread intent. Reading is saved by the shared unread owner, through the reader's dwell and the row's explicit read. A Solo chat's top-level row reads in one save, as its live bottom does: the channel catch-up mark reads its ordinary responses, and mentions are read individually, so its thread rows stay unread. As in a plain channel visit, the visible selected side tab reads while focus stays anywhere on the page, such as on the row that opened it; a tab moved to the main pane reads only by its own focus. Unlike a restored Channels side tab, an Inbox tab restored on returning to Inbox also reads, since it is still the selected conversation. Failed read actions retain their captured Retry unless access, the session or a newer intent retires it. A DM Retry reuses the original channel cutoff and manual-clear keys, not the retry-time clock or later arrivals. Re-clicking the selected row reselects its tab without repeating its read. Closing the tab ends the selection, so reopening the row reads it again. Archiving the selected conversation's channel, or its room becoming a Huddle, closes its tab and returns focus it held to the filters, and restoring the channel does not reopen it; other retained tabs follow Channels' unavailable placeholder, and focus their reader held moves to the tab. Re-clicking the selected row reveals its current message in its tab. A whole DM or session tab stays whole when its row regroups or reads into a thread; a thread tab follows its row, so a DM thread whose row no longer opens as a thread opens the whole DM. Context menus allow device-local Mark unread. Row accessible descriptions reuse the visible safe preview or incomplete placeholder; Show more follows the filtered result count. `NavigationItem` owns selected styling and `aria-current`. No Inbox-owned session, parallel fold, composer or outbox. Solo chats include ordinary incoming top-level messages, excluding agent-only coordination and system events. Their flat rows appear under All, not DMs, matching the sidebar's separate Solo and Direct messages sections; nested threads stay separate. Reading in Channels or Inbox clears the same evidence. diff --git a/docs/navigation-history-behavior.md b/docs/navigation-history-behavior.md index 2f5fc34815..2af748ee38 100644 --- a/docs/navigation-history-behavior.md +++ b/docs/navigation-history-behavior.md @@ -32,6 +32,14 @@ reveal left the reader less than 80 px from the end, Back keeps the message where the reveal placed it, but a plain visit opens at the latest message, as it does after the reader scrolls that close to the end. +A thread tab's source link and Show original message in channel/chat action +create a timeline visit, including when the source channel is already open. +They resolve the selected message's verified top-level source and seek it within +the existing history limit. The thread tab remains available with its draft; +Back restores the prior selection. Repeating the action reveals the source +again. A missing source produces an explicit failure and Retry instead of +opening another thread. Ordinary message links keep their existing placement. + Profiles and GitHub use the same rule. Their read-only target and optional thread context are carried as `detail` in the scoped conversation address; Back/Forward restores selection and reload resolves the panel through the diff --git a/src/bundled/channels/ChannelsPage.context.test.tsx b/src/bundled/channels/ChannelsPage.context.test.tsx index bd63464120..ae61269515 100644 --- a/src/bundled/channels/ChannelsPage.context.test.tsx +++ b/src/bundled/channels/ChannelsPage.context.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import type { ComponentProps, ReactElement } from "react"; import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; import { composerDOMFixture } from "../../features/messages/composer-testing"; @@ -531,3 +531,69 @@ it("Back to the visit seeks again once the channel has dropped the pages it read }), ); }); + +it("reveals a reply's verified source in cold timeline history without opening a thread", async () => { + const h = await channelsPage(); + const visit = h.visit( + h.target("alpha", { + messageId: fixture.reply.id, + threadRootId: "f".repeat(64), + context: "timeline", + }), + "source", + ); + h.go(visit); + await vi.waitFor(() => expect(h.held).toHaveLength(1)); + try { + expect(latest("alpha")?.holding).toBe(true); + expect( + screen.queryByRole("tab", { name: "Thread root" }), + ).not.toBeInTheDocument(); + } finally { + await h.release(); + } + await vi.waitFor(() => + expect(latest("alpha")).toMatchObject({ + holding: false, + navigation: { + target: { messageId: fixture.root.id, context: "timeline" }, + }, + }), + ); + expect( + screen.queryByRole("tab", { name: "Thread root" }), + ).not.toBeInTheDocument(); +}); + +it("reports an unavailable source instead of opening a replacement thread", async () => { + const h = await channelsPage({ root: false }); + h.go( + h.visit( + h.target("alpha", { messageId: fixture.reply.id, context: "timeline" }), + "source", + ), + ); + await screen.findByText("Couldn't show the original message."); + expect(screen.getByRole("button", { name: "Retry" })).toBeVisible(); + expect(latest("alpha")?.navigation).toBeUndefined(); +}); + +it("cancels an obsolete source seek when navigation leaves the channel", async () => { + const h = await channelsPage(); + h.go( + h.visit( + h.target("alpha", { messageId: fixture.root.id, context: "timeline" }), + "source", + ), + ); + await vi.waitFor(() => expect(h.held).toHaveLength(1)); + h.go(h.visit(h.target("beta"), "other")); + await h.release(); + await vi.waitFor(() => expect(latest("beta")?.holding).toBe(false)); + expect( + screen.queryByText("Couldn't show the original message."), + ).not.toBeInTheDocument(); + expect(latest("beta")?.navigation?.target).toMatchObject({ + channelId: "beta", + }); +}); diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 619dc8dd4d..2f8351701f 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -1120,7 +1120,7 @@ it("keeps a newer header move recoverable after an earlier one succeeds", async expect(screen.getByRole("button", { name: "Retry move" })).toBeVisible(); }); -it("opens another conversation's tab in its channel, but not the current channel's threads", async () => { +it("opens both current and other conversations from their tab menus", async () => { const open = vi.fn(async () => ({ status: "opened" as const })); const h = await page("stream", { open, @@ -1143,14 +1143,20 @@ it("opens another conversation's tab in its channel, but not the current channel }); return within(await screen.findByRole("menu")); }; - expect( - (await menu("Thread · Alpha")).queryByRole("menuitem", { - name: "Open in channel", + await h.user.click( + (await menu("Thread · Alpha")).getByRole("menuitem", { + name: "Show original message in channel", }), - ).not.toBeInTheDocument(); - await h.user.keyboard("{Escape}"); + ); + expect(open).toHaveBeenLastCalledWith( + expect.objectContaining({ + channelId: "alpha", + messageId: root, + context: "timeline", + }), + ); await h.user.click( - (await menu("Beta")).getByRole("menuitem", { name: "Open in channel" }), + (await menu("Beta")).getByRole("menuitem", { name: "Open channel" }), ); const scope = { viewer: h.viewer, @@ -1179,7 +1185,7 @@ it("opens another conversation's tab in its channel, but not the current channel ); await h.user.click( (await menu("Thread · Beta")).getByRole("menuitem", { - name: "Open in channel", + name: "Show original message in channel", }), ); await vi.waitFor(() => @@ -1189,8 +1195,7 @@ it("opens another conversation's tab in its channel, but not the current channel scope, channelId: "beta", messageId: other, - threadRootId: other, - context: "channel", + context: "timeline", }), ); }); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 83f9e70cbb..e541a354b0 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,3 +1,4 @@ +import { useSourceOpening } from "./useSourceOpening"; import { useChannelHistoryFocus } from "./useChannelHistoryFocus"; import type { Conversation } from "../../features/conversation/service"; import { StartLinkedChat, canStartLinkedChat } from "./StartLinkedChat"; @@ -673,6 +674,14 @@ function ChannelWorkspace({ navigation?.target.kind === "conversation" ? navigation.target.threadRootId : undefined; + const sourceRequested = + navigation?.target.kind === "conversation" && + navigation.target.context === "timeline"; + const sourceOpening = useSourceOpening( + queries, + !cached && !current?.cached ? current?.id : undefined, + sourceRequested ? navigation : undefined, + ); const membersRequested = navigation?.target.kind === "conversation" && navigation.target.panel === "members"; @@ -939,6 +948,7 @@ function ChannelWorkspace({ current?.cached || !navigation || !requestedMessage || + sourceRequested || requestedThread === requestedMessage || !currentId || navigation.signal.aborted @@ -966,6 +976,7 @@ function ChannelWorkspace({ navigation, requestedMessage, requestedThread, + sourceRequested, currentId, queries, ]); @@ -974,11 +985,13 @@ function ChannelWorkspace({ const exact = cached || current?.cached ? undefined - : navigation && requestedMessage && requestedThread === requestedMessage - ? { request: navigation, inTimeline: false } - : exactOpening?.request === navigation - ? exactOpening - : undefined; + : sourceRequested && navigation + ? { request: navigation, inTimeline: true } + : navigation && requestedMessage && requestedThread === requestedMessage + ? { request: navigation, inTimeline: false } + : exactOpening?.request === navigation + ? exactOpening + : undefined; // The route addresses a thread; its tab owns presentation, the route only // selects it and supplies the reveal request. const routedThread = useMemo( @@ -1473,6 +1486,7 @@ function ChannelWorkspace({ panelTrigger, splitTrigger, } = secondary; + const sourceAttempt = useRef(undefined); useLayoutEffect(() => { // Navigation temporarily withdraws the old presentation while preparing // the next destination. That handoff must not dismiss saved thread tabs. @@ -1514,6 +1528,16 @@ function ChannelWorkspace({ ? { channelId: currentId, target: detail.target } : undefined, ); + // Reopening the same source restarts its attempt without a new history + // entry. Apply that explicit intent once; traversal keeps saved selection. + if ( + sourceRequested && + !navigation.traversal && + sourceAttempt.current !== navigation + ) { + sourceAttempt.current = navigation; + tabState.selectMain(undefined); + } // A re-render while the return is pending must not reapply the detail // that was just closed. if (detail && currentId && navigation.entryId !== returned?.from) { @@ -1582,6 +1606,7 @@ function ChannelWorkspace({ draft, composingMessage, requestedMessage, + sourceRequested, navigation, requestedChannel, open, @@ -2046,6 +2071,12 @@ function ChannelWorkspace({ ref={historyContainer} className={`relative flex min-h-0 flex-col${current ? " flex-1" : ""}`} > + {sourceOpening.failed && ( +
+

Couldn't show the original message.

+ +
+ )} {current ? ( const thread = screen.getByRole("tab", { name: /Root text/ }); await user.pointer({ keys: "[MouseRight]", target: thread }); await user.click( - await screen.findByRole("menuitem", { name: "Open in channel" }), + await screen.findByRole("menuitem", { + name: "Show original message in channel", + }), ); await vi.waitFor(() => expect(focused).toBe(thread)); expect(page.navigation.open).toHaveBeenLastCalledWith({ @@ -388,8 +389,7 @@ it("opens a tab's conversation in its channel from the tab's menu", async () => scope, channelId: "alpha", messageId: "reply", - threadRootId: "root", - context: "channel", + context: "timeline", }); page.open({ channelId: "beta" }); await user.pointer({ @@ -397,7 +397,7 @@ it("opens a tab's conversation in its channel from the tab's menu", async () => target: screen.getByRole("tab", { name: /beta/ }), }); await user.click( - await screen.findByRole("menuitem", { name: "Open in channel" }), + await screen.findByRole("menuitem", { name: "Open channel" }), ); await vi.waitFor(() => expect(page.navigation.open).toHaveBeenLastCalledWith({ diff --git a/src/bundled/channels/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx index 6ff84c8cfc..bc517269b6 100644 --- a/src/bundled/channels/useConversationTabs.test.tsx +++ b/src/bundled/channels/useConversationTabs.test.tsx @@ -2180,3 +2180,83 @@ it("a hidden retained thread tab's rows re-render for new panels, not channel-li }); expect(rowRenders).toContain(`${roots.r1}:reply`); }); + +it("uses verified sources after reply deletion and revokes deferred source actions", () => { + const channels = [ + { id: "alpha", name: "Alpha" }, + { id: "beta", name: "Beta" }, + ]; + const session = { + viewer: "viewer", + channels: { list: () => ({ channels }), window: () => ({ rows: [] }) }, + } as unknown as RelaySession; + const available: RegisteredPanel[] = []; + const panels = { + snapshot: () => available, + subscribe: () => () => {}, + } as unknown as Panels; + const relay = { + snapshot: () => ({ status: "ready", session }), + } as unknown as RelayData; + const open = vi.fn(); + const h = renderHook(() => { + const state = useChannelTabState(session, "alpha"); + const workspace = useConversationTabs({ + state, + relay, + session, + panels, + current: channels[0], + channels, + profiles: new Map(), + scope: "origin:viewer", + conversationIcon: () => null, + leadingIds: [], + continuingVisit: true, + isLive: () => true, + openLink: () => false, + navigation: { open }, + }); + return { state, workspace }; + }); + act(() => + h.result.current.state.openThreadTab( + "beta", + "deleted-reply", + "provisional-hint", + ), + ); + const sourceAction = () => { + const item = h.result.current.workspace.items[0]; + return item && "actions" in item ? item.actions?.[0] : undefined; + }; + // Unresolved ancestry must use the selected message, never a link's hint. + act(() => sourceAction()?.run()); + expect(open).toHaveBeenLastCalledWith( + expect.objectContaining({ + messageId: "deleted-reply", + context: "timeline", + }), + ); + // The reader verified this root before the selected reply disappeared. + // A deleted selection does not make its retained original unavailable. + act(() => + h.result.current.state.resolveThreadRoot( + "thread:beta:provisional-hint", + "verified-root", + ), + ); + const action = sourceAction(); + expect(action).toBeDefined(); + act(() => action?.run()); + expect(open).toHaveBeenLastCalledWith( + expect.objectContaining({ + messageId: "verified-root", + context: "timeline", + }), + ); + open.mockClear(); + h.unmount(); + action?.run(); + expect(open).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/channels/useConversationTabs.tsx b/src/bundled/channels/useConversationTabs.tsx index 29938adc7c..3f985adc18 100644 --- a/src/bundled/channels/useConversationTabs.tsx +++ b/src/bundled/channels/useConversationTabs.tsx @@ -785,10 +785,14 @@ export function useConversationTabs({ .slice(0, 80); return text || (name ? `Thread · ${name}` : "Thread"); } - // The current conversation's own threads are already in its channel. function openInChannel(tab: Tab, target: ChannelSummary | undefined) { - return target && navigation && viewer && tab.channelId !== currentId - ? () => + // A verified root stays usable even if the selected reply was deleted. + // An unresolved tab's id can contain a hint, so fall back to its message. + const messageId = + tab.kind === "thread" ? (tab.rootId ?? tab.messageId) : tab.messageId; + return target && navigation && viewer + ? () => { + if (!currentVisit()) return; void navigation.open({ version: 1, kind: "conversation", @@ -797,18 +801,27 @@ export function useConversationTabs({ communityOrigin: scope.slice(0, -(viewer.length + 1)), }, channelId: tab.channelId, - ...(tab.messageId && { messageId: tab.messageId }), - // The channel shows the thread's root beside it, for its context. - ...(tab.kind === "thread" && { - threadRootId: rootOfTab(tab), - context: "channel", + ...(messageId && { + messageId, + context: "timeline", }), - }) + }); + } : undefined; } // A thread names its conversation as Inbox rows do, bounding a group DM's // names where its tab lists them all; the name opens it there. const resolveName = useIdentityNames(queries.names); + function sourceAction(tab: Tab, target: ChannelSummary) { + const place = ["dm", "solo"].includes(target.channelType ?? "stream") + ? "chat" + : "channel"; + return tab.kind === "thread" + ? `Show original message in ${place}` + : tab.messageId + ? `Show message in ${place}` + : `Open ${place}`; + } function parentOf(tab: Tab, target: ChannelSummary) { const open = openInChannel(tab, target); return ( @@ -816,7 +829,10 @@ export function useConversationTabs({ label: target.channelType === "dm" ? `DM · ${dmLabel(target.participants, profiles, resolveName, target.name)}` - : `#${target.name}`, + : target.channelType === "solo" + ? target.name + : `#${target.name}`, + action: sourceAction(tab, target), open, } ); @@ -847,9 +863,10 @@ export function useConversationTabs({ move: (delta: -1 | 1) => moveBy(tab, delta), rename: (name: string) => tabState.renameTab(tab.id, name), }), - ...(openChannel && { - actions: [{ label: "Open in channel", run: openChannel }], - }), + ...(openChannel && + target && { + actions: [{ label: sourceAction(tab, target), run: openChannel }], + }), content: !usable || !target ? (

diff --git a/src/bundled/channels/useSourceOpening.ts b/src/bundled/channels/useSourceOpening.ts new file mode 100644 index 0000000000..c44cd6a108 --- /dev/null +++ b/src/bundled/channels/useSourceOpening.ts @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { PageNavigation } from "../../features/navigation/service"; +import type { RelaySession } from "../../features/relay/session"; +import { useMessageOpening } from "../../features/messages/use-message-opening"; + +/** Resolve ancestry from signed message evidence, then seek its timeline row. + * A link's thread hint never decides where Show original message lands. */ +export function useSourceOpening( + session: RelaySession, + channelId: string | undefined, + navigation: PageNavigation | undefined, +) { + const messageId = + navigation?.target.kind === "conversation" + ? navigation.target.messageId + : undefined; + const [resolved, setResolved] = useState<{ + request: PageNavigation; + rootId?: string | undefined; + }>(); + useEffect(() => { + if (!channelId || !navigation || !messageId || navigation.signal.aborted) + return; + const request = navigation; + const window = session.channels.window(channelId); + if ( + window.status === "ready" && + window.freshness !== "cached" && + window.rows.some((row) => row.id === messageId && !row.threadRootId) + ) { + setResolved({ request, rootId: messageId }); + return; + } + let active = true; + let view: ReturnType | undefined; + const dispose = () => { + active = false; + view?.dispose(); + }; + navigation.signal.addEventListener("abort", dispose, { once: true }); + void (async () => { + try { + view = session.thread(channelId, messageId, { exact: true }); + await view.refresh(); + if (active && !request.signal.aborted) + setResolved({ request, rootId: view.snapshot().root?.id }); + } catch { + if (active && !request.signal.aborted) setResolved({ request }); + } finally { + view?.dispose(); + } + })(); + return () => { + navigation.signal.removeEventListener("abort", dispose); + dispose(); + }; + }, [session, channelId, navigation, messageId]); + const rootId = + resolved?.request === navigation ? resolved?.rootId : undefined; + const visit = useRef({ navigation, request: 0 }); + if (visit.current.navigation !== navigation) + visit.current = { navigation, request: visit.current.request + 1 }; + const inTimeline = useMessageOpening( + session.channels, + channelId ?? "", + rootId, + visit.current.request, + ); + const failed = + !!navigation && + resolved?.request === navigation && + (!rootId || inTimeline === false); + useEffect(() => { + if (failed && !navigation?.signal.aborted) + navigation?.complete({ + status: "failed", + reason: "not-found", + presented: true, + }); + }, [failed, navigation]); + const timeline = useMemo( + () => + navigation?.target.kind === "conversation" && rootId && inTimeline + ? { ...navigation, target: { ...navigation.target, messageId: rootId } } + : undefined, + [navigation, rootId, inTimeline], + ); + return { + navigation: timeline, + failed, + holding: !!navigation && !failed && !timeline, + }; +} diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index f210954af5..7b90f3b998 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -109,7 +109,7 @@ export type ThreadPanelProps = { close?: (() => void) | undefined; /** The conversation this thread lives in, named as Inbox rows name it, when * the host can show the thread there. */ - parent?: { label: string; open(): void } | undefined; + parent?: { label: string; action?: string; open(): void } | undefined; onOpenLink(url: string, origin?: MessageLinkOrigin): boolean; onOpenMediaReview?( messageId: string, @@ -169,7 +169,13 @@ export function ThreadPanel(props: ThreadPanelProps) { {props.parent && (

In{" "} -

diff --git a/src/features/navigation/targets.test.ts b/src/features/navigation/targets.test.ts index 48e78b0e7c..31f0ceef8d 100644 --- a/src/features/navigation/targets.test.ts +++ b/src/features/navigation/targets.test.ts @@ -87,6 +87,14 @@ describe("open target boundary", () => { expect(target).toEqual({ ...parseOpenTarget(thread), context: "channel" }); expect(targetKey(target)).not.toBe(targetKey(thread)); }); + it("keeps a timeline source visit distinct from ordinary message links", () => { + const target = parseOpenTarget({ ...conversation, context: "timeline" }); + expect(target).toEqual({ + ...parseOpenTarget(conversation), + context: "timeline", + }); + expect(targetKey(target)).not.toBe(targetKey(conversation)); + }); it("opens a saved Shared sessions target as its conversation", () => { const target = parseOpenTarget({ ...conversation, @@ -170,7 +178,7 @@ describe("open target boundary", () => { { ...conversation, messageId: undefined, threadRootId: "b".repeat(64) }, // Context names where a thread's root shows; it needs that root. { ...conversation, context: "channel" }, - { ...conversation, threadRootId: "b".repeat(64), context: "timeline" }, + { ...conversation, messageId: undefined, context: "timeline" }, { ...conversation, channelId: "../../other" }, { version: 1, kind: "page", pluginId: "a/b", pageId: "board" }, { diff --git a/src/features/navigation/targets.ts b/src/features/navigation/targets.ts index 59cda36703..cfa102afd3 100644 --- a/src/features/navigation/targets.ts +++ b/src/features/navigation/targets.ts @@ -66,9 +66,9 @@ export type OpenTarget = messageId?: string; /** Hint only. Resolve the actual root from verified message evidence. */ threadRootId?: string; - /** With threadRootId: the thread opens beside that root, revealed in the - * channel timeline even beyond its newest page, as Open in channel asks. */ - context?: "channel"; + /** Timeline: reveal the verified top-level source without opening a thread. + * Channel: legacy thread-plus-root presentation, requiring threadRootId. */ + context?: "channel" | "timeline"; /** Presentation within this conversation; never mutation or confirmation state. */ panel?: "members"; /** Message selected for the forwarding dialog; never submission state. */ @@ -396,7 +396,10 @@ export function parseOpenTarget(input: unknown): OpenTarget { throw invalid(); if ( value.context !== undefined && - (value.context !== "channel" || value.threadRootId === undefined) + !( + (value.context === "channel" && value.threadRootId !== undefined) || + (value.context === "timeline" && value.messageId !== undefined) + ) ) throw invalid(); if ( @@ -431,7 +434,9 @@ export function parseOpenTarget(input: unknown): OpenTarget { ...(value.threadRootId !== undefined ? { threadRootId: text(value.threadRootId, hex).toLowerCase() } : {}), - ...(value.context === "channel" ? { context: value.context } : {}), + ...(value.context === "channel" || value.context === "timeline" + ? { context: value.context } + : {}), ...(value.panel === "members" ? { panel: value.panel } : {}), ...(value.forward !== undefined ? { forward: text(value.forward, hex).toLowerCase() } diff --git a/tests/browser/channel-tabs.spec.mjs b/tests/browser/channel-tabs.spec.mjs index 46c523639b..73f4e9533f 100644 --- a/tests/browser/channel-tabs.spec.mjs +++ b/tests/browser/channel-tabs.spec.mjs @@ -89,7 +89,7 @@ test("middle-click closes a content tab without selecting it", async ({ ).toBeFocused(); }); -test("Open in channel takes a content tab to its conversation with its composer focused", async ({ +test("Open chat takes a content tab to its conversation with its composer focused", async ({ page, app, }) => { @@ -108,9 +108,7 @@ test("Open in channel takes a content tab to its conversation with its composer .locator("[data-panel-workspace]") .getByRole("tab", { name: "Beta", exact: true }) .click({ button: "right" }); - await page - .getByRole("menuitem", { name: "Open in channel", exact: true }) - .click(); + await page.getByRole("menuitem", { name: "Open chat", exact: true }).click(); await expect(page.getByRole("menu")).toHaveCount(0); await expect( page.evaluate(() => history.state.buzzNavigationV1.entry.target), @@ -624,3 +622,90 @@ test.describe("archived Canvas", () => { ).toBeDisabled(); }); }); + +// Real focus, virtualized positioning and browser Back/Forward cannot be +// established by the owner-level routing tests. +test("a same-channel source action retains a main thread and its draft", async ({ + page, + app, +}, testInfo) => { + await open(page, app); + const root = app.append( + "primary", + "alpha", + "Source navigation root", + true, + false, + ); + app.append( + "primary", + "alpha", + "Source navigation reply", + true, + false, + root.id, + ); + const timeline = page.locator('[data-channel-timeline="alpha"]'); + const row = timeline.locator(`[data-message-id="${root.id}"]`); + await hoverMessage(row); + await row.getByRole("button", { name: "Reply", exact: true }).click(); + const thread = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + const draft = thread.getByRole("textbox", { + name: "Reply to thread", + exact: true, + }); + await draft.fill("Keep source draft"); + const tab = page.locator( + `[role="tab"][data-tab-value="thread:alpha:${root.id}"]`, + ); + await tab.focus(); + await page.keyboard.press("Alt+Shift+ArrowLeft"); + await expect(tab).toBeFocused(); + const source = thread.getByRole("button", { name: "#Alpha", exact: true }); + for (const theme of ["light", "dark"]) { + await page.evaluate((mode) => { + document.documentElement.dataset.colorMode = mode; + }, theme); + for (const width of [1440, 800]) { + await page.setViewportSize({ width, height: 950 }); + await page.evaluate(() => { + document.documentElement.style.fontSize = "20px"; + }); + await expect(source).toBeInViewport(); + await page.screenshot({ + path: testInfo.outputPath(`source-${theme}-${width}.png`), + }); + } + } + await page.evaluate(() => { + document.documentElement.style.fontSize = ""; + }); + await page.setViewportSize({ width: 1440, height: 950 }); + await source.focus(); + await page.keyboard.press("Enter"); + await expect(row).toBeInViewport(); + await expect(row).toBeFocused(); + await expect(tab).toHaveAttribute("aria-selected", "false"); + await page.goBack(); + await expect(tab).toHaveAttribute("aria-selected", "true"); + await expect(draft).toHaveText("Keep source draft"); + await page.goForward(); + await expect(row).toBeInViewport(); + await tab.click({ button: "right" }); + await page + .getByRole("menuitem", { + name: "Show original message in channel", + exact: true, + }) + .click(); + await expect(row).toBeFocused(); + await expect(tab).toHaveCount(1); + await tab.click(); + await expect(draft).toHaveText("Keep source draft"); + await source.click(); + await expect(tab).toHaveAttribute("aria-selected", "false"); + await expect(row).toBeFocused(); +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index fc919c56d7..a6d39f9628 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2879,11 +2879,11 @@ export const test = base.extend({ relay.publish("primary", event); }, // Relay-owned summary evidence, not a mutation of the client's row. - threadSummary(rootId, participants, replyCount) { + threadSummary(rootId, participants, replyCount, channel = "alpha") { const event = sign( 39005, [ - ["h", "alpha"], + ["h", channel], ["e", rootId], ["d", rootId], ], @@ -2895,7 +2895,7 @@ export const test = base.extend({ if (relay) relay.publish("primary", event); else for (const client of streams.get("primary") ?? []) - if (client.channels.includes("alpha")) + if (client.channels.includes(channel)) client.response.write(`data: ${JSON.stringify(event)}\n\n`); return event; }, diff --git a/tests/browser/inbox-tabs.spec.mjs b/tests/browser/inbox-tabs.spec.mjs index 47c8e613ef..a28a8d4bce 100644 --- a/tests/browser/inbox-tabs.spec.mjs +++ b/tests/browser/inbox-tabs.spec.mjs @@ -139,7 +139,7 @@ test("Inbox rows open their conversation in one side tab beside the list", async // the channel's root row is in view and painted beside the focused thread. test.describe("with the thread's root beyond the channel's newest page", () => { test.use({ threadUnreadNewerRows: 30 }); - test("an Inbox tab's menu opens its thread in its channel beside its root", async ({ + test("an Inbox tab reveals its original in cold channel history", async ({ page, app, }) => { @@ -182,21 +182,16 @@ test.describe("with the thread's root beyond the channel's newest page", () => { .getByRole("tablist", { name: "Panel tabs" }) .getByRole("tab", { name: /^Thread/ }) .click({ button: "right" }); - await page.getByRole("menuitem", { name: "Open in channel" }).click(); + await page + .getByRole("menuitem", { name: "Show original message in channel" }) + .click(); await expect(inbox).toHaveCount(0); const target = await page.evaluate( () => history.state.buzzNavigationV1.entry.target, ); - expect(target.threadRootId).toBe(root.id); + expect(target.context).toBe("timeline"); expect(target.messageId).toMatch(/^[a-f0-9]{64}$/); - // The thread still opens at the same message, which keeps focus. - const reply = page - .getByRole("region", { name: "Thread messages", exact: true }) - .locator(`[data-message-id="${target.messageId}"]`); - await expect(reply).toContainText("Unread reply 1"); - await expect(reply).toBeFocused(); - // Beside it, the channel waits to show its list at the thread's root, - // reading the older page the fixture holds. + // The destination owns timeline focus; the source tab remains in Activity. await expect.poll(() => app.pending.length).toBe(1); expect(app.pending[0].channel).toBe("alpha"); await expect( @@ -204,7 +199,6 @@ test.describe("with the thread's root beyond the channel's newest page", () => { .getByRole("region", { name: "Channel history", exact: true }) .getByRole("status"), ).toHaveText("Loading messages…"); - await expect(reply).toBeFocused(); app.pending.shift().release(); const head = app.report.queries.find( ({ community, filter }) => @@ -223,7 +217,24 @@ test.describe("with the thread's root beyond the channel's newest page", () => { .locator(`[data-message-id="${root.id}"]`), ).toBeInViewport(); await expect.poll(() => page.evaluate(() => window.rootMarked)).toBe(true); - await expect(reply).toBeFocused(); + await expect( + page.locator( + `[data-channel-timeline="alpha"] [data-message-id="${root.id}"]`, + ), + ).toBeFocused(); + await page.goBack(); + await expect(inbox).toBeVisible(); + await expect( + inbox + .getByRole("region", { name: "Thread messages", exact: true }) + .getByText("Unread reply 1", { exact: true }), + ).toBeVisible(); + await page.goForward(); + await expect( + page.locator( + `[data-channel-timeline="alpha"] [data-message-id="${root.id}"]`, + ), + ).toBeInViewport(); }); }); @@ -341,7 +352,19 @@ for (const variant of Object.keys(titles)) await channel.click(); await expect(inbox).toHaveCount(0); if (moved) { - app.append( + // This source predates the head page; release the fixture's held seek + // before asking the timeline to display its live reply summary. + const sourceRow = page.locator( + `[data-channel-timeline="beta"] [data-message-id="${roots[3].id}"]`, + ); + await expect + .poll(async () => { + for (const pending of app.pending.splice(0)) pending.release(); + return sourceRow.count(); + }) + .toBe(1); + await expect(sourceRow).toBeFocused(); + const freshReply = app.append( "primary", "beta", "Fresh Beta mention 3", @@ -351,10 +374,16 @@ for (const variant of Object.keys(titles)) undefined, [["p", app.viewer]], ); - // Delivered to the open thread, so the session has the row's move. + // Model the relay-owned summary separately from the authored reply. + app.threadSummary(roots[3].id, [freshReply.pubkey], 2, "beta"); + // The summary follows the reply on the live stream, establishing receipt. await expect( - page.getByText("Fresh Beta mention 3", { exact: true }), - ).toBeVisible(); + page + .locator( + `[data-channel-timeline="beta"] [data-message-id="${roots[3].id}"]`, + ) + .getByRole("button", { name: /^View thread:/ }), + ).toContainText("2 replies"); } if (variant === "shifted") { // A new mentioned thread adds a row above, shifting the rest down.