diff --git a/docs/buzz-help.md b/docs/buzz-help.md index e03f930fdb..37de6039b3 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -349,6 +349,9 @@ revokes all of them, and a pairing unused for 30 days expires. 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. +- **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. - **Mute** is available from a channel or DM's sidebar context menu. Once confirmed, a crossed-out bell appears immediately after its name. New messages do not make muted channel or DM names bold. diff --git a/docs/inbox.md b/docs/inbox.md index ada8e55a90..da06ed9a09 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -50,8 +50,11 @@ Resting a pointer on the sidebar's Activity row opens a card of recent conversat through the page contract's `preview`. It reads the same `session.unread.inbox()` and `session.inboxFeed` projection as the page, with All activity and Everyone, newest first and bounded to 50 rows after mixing conversations and reactions, as on the -page. The header counts unread -conversations once each, as a DM, mention or thread. Unread only is the card's own +page. The header leads with the number of +unread conversations, then counts each once by its most specific kind: DM, then +mention, then thread. A mention counts only while it is unread, as Mentions with +Unread only would list it; a Solo chat's flat row has no kind, so only the total +counts it. Unread only is the card's own choice, saved on this device per account and community, and never changes the page's filters. A row opens its conversation directly, through the same conversation target as the page's fallback, and closes the card without opening diff --git a/docs/unread.md b/docs/unread.md index de4a092b96..371663b758 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -51,9 +51,11 @@ 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 -catch-up (including a message taller than the viewport): +Away from the bottom, observations mark individual fully visible messages, to +within a pixel: a reveal's whole-pixel scroll can leave a row a fraction of a +pixel past the edge. At the physical bottom, 300 ms visibility of the newest +message's bottom edge also earns catch-up (including a message taller than the +viewport): - `activity:` uses the newest retained verified event timestamp at dwell completion, including replies newer than the visible top-level head. It reads @@ -222,8 +224,8 @@ do not contribute to “N unread” offscreen counts or avatar previews, even fo mentions, manual unread or thread replies. Muted channel and DM sidebar rows show a crossed-out bell immediately after their names, even with no unread activity. They hide all unread dots, including -thread activity dots. Thread activity remains available through the row's activity popover, -and its unread count remains accessible to assistive technology. Unmute restores +thread activity dots. Mentions and thread activity remain available through the row's activity +popover, and their counts remain accessible to assistive technology. Unmute restores the dots from retained unread evidence. The promoted Bestie DM also hides muted unread dots; its label follows the same mute rule. Solo chat labels strengthen for any observed unread message, including mentions, @@ -232,8 +234,17 @@ Ordinary unread renders no row marker. Unmuted DMs, mentions, broadcasts, and relevant thread replies add one accent dot; non-DM row numerals are omitted and DM avatars are reserved for promoted offscreen cues. Thread activity reuses that dot: its hover/focus/click popover groups unread replies by canonical thread root and -opens the existing thread panel, so overlapping priority and thread activity never -produce duplicate dots. Merely revealing the popover does not acknowledge a reply. +opens the existing thread panel. Above the threads it lists unread top-level messages that +mention the viewer, newest first; a mention opens at its message and is read like any +visit. A reply's mention stays under its thread; DMs and broadcasts list no +mentions. A row with only mentions keeps its priority dot, names them to +assistive technology ("1 unread mention") and still opens the popover; with +threads, the dot's label counts both ("1 unread mention, 1 unread thread"). +Overlapping priority, mention and thread activity never produce duplicate dots. +Merely revealing the popover does not acknowledge a mention or reply. +While Activity is still checking a mention's stored edits and deletions, its row +shows "Preview updating…" instead of the text, or "Preview unavailable. Retry +activity." if the check failed, as on the Activity page. A local manual-unread mark strengthens the label without fabricating priority; the underlying observed count remains available. Channel Settings → Diagnostics exposes explicit actions and Unread status/retry. Unknown and diff --git a/src/bundled/channels/ChannelActivityPopover.test.tsx b/src/bundled/channels/ChannelActivityPopover.test.tsx index 583dbef025..96c79dc7a0 100644 --- a/src/bundled/channels/ChannelActivityPopover.test.tsx +++ b/src/bundled/channels/ChannelActivityPopover.test.tsx @@ -1,6 +1,13 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, test, vi } from "vitest"; import { ChannelActivityPopover } from "./ChannelActivityPopover"; @@ -8,12 +15,21 @@ import { Button } from "../../shared/design-system/ui/Button"; import { ChannelSidebarRows } from "../../features/channel-navigation/ChannelSidebarRows"; import type { RelaySession } from "../../features/relay/session"; import type { + MentionActivityItem, ThreadActivityItem, ThreadActivitySnapshot, } from "../../features/relay/unread"; +import { createInboxFeed } from "../../features/relay/inbox-feed"; +import type { InboxFeedSnapshot } from "../../features/relay/inbox-feed"; +import type { ChannelQueries } from "../../features/relay/contracts"; +import type { RelayEvent } from "../../features/relay/events"; afterEach(cleanup); +const settled: InboxFeedSnapshot = { status: "ready", incomplete: [] }; +// No message's edits or deletions are still being checked. +const settledFeed = { snapshot: () => settled, subscribe: () => () => {} }; + test("closes activity while its row exits and does not reopen on re-entry", async () => { const user = userEvent.setup(); const snapshot: ThreadActivitySnapshot = { @@ -42,6 +58,7 @@ test("closes activity while its row exits and does not reopen on re-entry", asyn trigger={} agents={["a".repeat(64)]} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ] : []} @@ -90,6 +107,7 @@ test("mouse presses do not pin the hover preview open, and only hover marks it a ensure: async () => {}, }, agentChoices: { snapshot: () => ({ identities: [] }) }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( Activity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); const trigger = screen.getByRole("button", { name: "Activity" }); @@ -153,6 +172,7 @@ test("activity keeps profile loading lazy, explains stale results and opens the markThrough, }, profiles: { snapshot: () => profiles, subscribe: () => () => {}, ensure }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( Activity} onOpenThread={open} + onOpenMention={vi.fn()} />, ); expect(ensure).not.toHaveBeenCalled(); @@ -212,6 +233,236 @@ test("activity keeps profile loading lazy, explains stale results and opens the ); }); +test("unread mentions open activity alone, list above threads, open by message and read by message", async () => { + const user = userEvent.setup(); + const mention: MentionActivityItem = { + channelId: "studio", + messageId: "mention", + authorId: "sam", + createdAt: 2, + preview: "@you can you sign off?", + }; + const thread: ThreadActivityItem = { + channelId: "studio", + rootId: "thread", + latestMessageId: "reply", + authorId: "alex", + createdAt: 1, + preview: "Please review the designs", + unreadCount: 1, + }; + let snapshot: ThreadActivitySnapshot = { + channelId: "studio", + items: [], + mentions: [mention], + coverage: "observed", + freshness: "observed", + }; + const listeners = new Set<() => void>(); + const profiles = new Map([ + ["sam", { name: "Sam" }], + ["alex", { name: "Alex" }], + ]); + const ensure = vi.fn(async () => {}); + const openMention = vi.fn(); + const openThread = vi.fn(); + const markThrough = vi + .fn() + .mockRejectedValueOnce(new Error("Storage unavailable")) + .mockResolvedValueOnce({ durability: "saved" }); + const session = { + agentChoices: { snapshot: () => ({ identities: [] }) }, + unread: { + activity: () => snapshot, + subscribeActivity: (_: string, listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + markThrough, + }, + profiles: { snapshot: () => profiles, subscribe: () => () => {}, ensure }, + inboxFeed: settledFeed, + } as unknown as RelaySession; + render( + Activity} + onOpenThread={openThread} + onOpenMention={openMention} + />, + ); + const trigger = screen.getByRole("button", { name: "Activity" }); + await user.hover(trigger); + const popup = await screen.findByRole("dialog", { + name: "Activity in Studio", + }); + expect( + screen.getByRole("heading", { level: 2, name: "Mentions" }), + ).toBeVisible(); + expect(screen.queryByRole("region", { name: "Unread threads" })).toBeNull(); + // Revealing the popover reads nothing. + expect(markThrough).not.toHaveBeenCalled(); + const markRead = screen.getByRole("button", { + name: "Mark mention from Sam as read", + }); + await user.click(markRead); + expect(markThrough).toHaveBeenCalledExactlyOnceWith( + { kind: "message", channelId: "studio", messageId: "mention" }, + "mention", + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not mark this mention as read. Try again.", + ); + await user.click(markRead); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + snapshot = { ...snapshot, items: [thread] }; + act(() => { + for (const listener of listeners) listener(); + }); + expect( + within(popup) + .getAllByRole("region") + .map((section) => section.getAttribute("aria-label")), + ).toEqual(["Mentions", "Unread threads"]); + await user.click( + screen.getByRole("button", { + name: "Open unread mention from Sam: @you can you sign off?", + }), + ); + expect(openMention).toHaveBeenCalledExactlyOnceWith(mention); + expect(openThread).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + trigger.focus(); + await user.keyboard("{Enter}"); + await screen.findByRole("dialog", { name: "Activity in Studio" }); + expect(ensure).toHaveBeenLastCalledWith(["sam", "alex"], "background"); +}); + +test("a mention whose edits or deletions are unchecked shows the Inbox placeholder until they settle", async () => { + const user = userEvent.setup(); + const body = "@you can you sign off?"; + const mention: MentionActivityItem = { + channelId: "studio", + messageId: "mention", + authorId: "sam", + createdAt: 2, + preview: body, + }; + const snapshot: ThreadActivitySnapshot = { + channelId: "studio", + items: [], + mentions: [mention], + coverage: "observed", + freshness: "observed", + }; + const event = { + id: mention.messageId, + kind: 9, + pubkey: mention.authorId, + created_at: mention.createdAt, + content: body, + tags: [["p", "you"]], + } as unknown as RelayEvent; + // Each attempt's edit/deletion read of the mention is held until released. + const reads: { + resolve(events: RelayEvent[]): void; + reject(error: Error): void; + }[] = []; + const feed = createInboxFeed({ + viewer: "you", + channels: { + list: () => ({ channels: [] }), + subscribeList: () => () => {}, + } as unknown as ChannelQueries, + reader: { + read: ([filter]) => + filter?.["#e"]?.includes(event.id) + ? new Promise((resolve, reject) => reads.push({ resolve, reject })) + : Promise.resolve([]), + }, + async addressedRead(_filter, _signal, prepare) { + prepare([event]); + return [event]; + }, + retainedEvent: (id) => (id === event.id ? event : undefined), + retainedEditIds: () => [], + }); + const profiles = new Map([["sam", { name: "Sam" }]]); + const session = { + agentChoices: { snapshot: () => ({ identities: [] }) }, + unread: { activity: () => snapshot, subscribeActivity: () => () => {} }, + profiles: { + snapshot: () => profiles, + subscribe: () => () => {}, + ensure: async () => {}, + }, + inboxFeed: feed, + } as unknown as RelaySession; + render( + Activity} + onOpenThread={vi.fn()} + onOpenMention={vi.fn()} + />, + ); + const row = (preview: string) => + screen.getByRole("button", { + name: `Open unread mention from Sam: ${preview}`, + }); + try { + screen.getByRole("button", { name: "Activity" }).focus(); + await user.keyboard("{Enter}"); + const popup = await screen.findByRole("dialog", { + name: "Activity in Studio", + }); + expect(row(body)).toHaveTextContent(body); + let work = Promise.resolve(); + await act(async () => { + work = feed.ensure(); + }); + await waitFor(() => expect(reads).toHaveLength(1)); + expect(feed.snapshot()).toMatchObject({ + status: "loading", + incomplete: [event.id], + }); + expect(row("Preview updating…")).toHaveTextContent("Preview updating…"); + expect(popup).not.toHaveTextContent(body); + await act(async () => { + reads[0]?.reject(new Error("Edits unavailable")); + await work; + }); + expect(feed.snapshot()).toMatchObject({ + status: "error", + incomplete: [event.id], + }); + expect(row("Preview unavailable. Retry activity.")).toHaveTextContent( + "Preview unavailable. Retry activity.", + ); + expect(popup).not.toHaveTextContent(body); + await act(async () => { + work = feed.refresh(); + }); + await waitFor(() => expect(reads).toHaveLength(2)); + expect(row("Preview updating…")).toHaveTextContent("Preview updating…"); + await act(async () => { + reads[1]?.resolve([]); + await work; + }); + expect(feed.snapshot()).toMatchObject({ status: "ready", incomplete: [] }); + expect(row(body)).toHaveTextContent(body); + } finally { + for (const read of reads) read.resolve([]); + feed.dispose(); + } +}); + test("working agents open their thread when linked, otherwise the conversation", async () => { const user = userEvent.setup(); const first = "a".repeat(64); @@ -281,6 +532,7 @@ test("working agents open their thread when linked, otherwise the conversation", onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); screen.getByRole("button", { name: "Activity" }).focus(); @@ -374,6 +626,7 @@ test("a typing agent still opens its profile activity while Agent Activity is of onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); screen.getByRole("button", { name: "Activity" }).focus(); @@ -421,6 +674,7 @@ test("a press while there is no activity does not block the hover preview once a ensure: async () => {}, }, agentChoices: { snapshot: () => ({ identities: [] }) }, + inboxFeed: settledFeed, } as unknown as RelaySession; render( Activity} onOpenThread={vi.fn()} + onOpenMention={vi.fn()} />, ); const trigger = screen.getByRole("button", { name: "Activity" }); diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx index 3bcd3f93a1..c960239980 100644 --- a/src/bundled/channels/ChannelActivityPopover.tsx +++ b/src/bundled/channels/ChannelActivityPopover.tsx @@ -23,7 +23,10 @@ import { import { selectProfiles } from "../../features/relay/profile-selection"; import type { Profile } from "../../features/relay/contracts"; import type { RelaySession } from "../../features/relay/session"; -import type { ThreadActivityItem } from "../../features/relay/unread"; +import type { + MentionActivityItem, + ThreadActivityItem, +} from "../../features/relay/unread"; import { Avatar } from "../../shared/Avatar"; import { Avatar as WorkflowAvatar } from "../../shared/design-system/ui/Avatar"; import { publicKeyLabels } from "../../shared/identity/public-key"; @@ -32,6 +35,7 @@ import activityStyles from "../../features/agents/ActivityRows.module.css"; import { AgentActivityRow } from "../../features/agents/AgentActivityRow"; import { workingAgentDetails } from "./working-agents"; import { avatarMedia } from "../../shared/avatar-source"; +import { incompletePreview } from "../inbox/InboxRow"; const elapsed = (createdAt: number, now = Date.now()) => { const seconds = Math.max(0, Math.floor(now / 1000) - createdAt); @@ -51,14 +55,22 @@ const noSubscribe = () => () => {}; function ActivityRow({ item, + noun, + previewId, session, agents, onOpen, + read, }: { - item: ThreadActivityItem; + item: ThreadActivityItem | MentionActivityItem; + noun: "thread" | "mention"; + /** A message whose body Activity must not show until its edits and + * deletions are checked. */ + previewId?: string; session: RelaySession; agents: readonly string[]; - onOpen(item: ThreadActivityItem): void; + onOpen(): void; + read(): Promise; }) { const selection = useMemo( () => @@ -78,6 +90,12 @@ function ActivityRow({ profile?.name ?? displayId.slice(0, 10), ); const name = item.workflowOwnerId ? workflowLabel(resolved) : resolved; + const feed = useSyncExternalStore( + session.inboxFeed.subscribe, + session.inboxFeed.snapshot, + session.inboxFeed.snapshot, + ); + const preview = incompletePreview(feed, [previewId]) ?? item.preview; const isAgent = agents.includes(item.authorId) || !!profile?.isAgent || @@ -91,10 +109,7 @@ function ActivityRow({ setPending(true); setError(false); try { - await session.unread.markThrough( - { kind: "thread", channelId: item.channelId, rootId: item.rootId }, - item.latestMessageId, - ); + await read(); } catch { setError(true); } finally { @@ -107,8 +122,8 @@ function ActivityRow({ > onOpen(item)} + aria-label={`Open unread ${noun} from ${name}: ${preview}`} + onClick={onOpen} icon={ item.workflowOwnerId ? ( - {item.preview} + {preview} } /> @@ -142,7 +157,7 @@ function ActivityRow({ } - aria-label={`Mark thread from ${name} as read`} + aria-label={`Mark ${noun} from ${name} as read`} title="Mark as read" loading={pending} onClick={() => void markRead()} @@ -150,7 +165,7 @@ function ActivityRow({ {error && (

- Could not mark this thread as read. Try again. + Could not mark this {noun} as read. Try again.

)} @@ -167,6 +182,7 @@ export function ChannelActivityPopover({ onOpenWorkingAgent, onOpenAgentActivity, onOpenThread, + onOpenMention, }: { session: RelaySession; channelId: string; @@ -185,6 +201,7 @@ export function ChannelActivityPopover({ messageId: string | undefined, ) => void; onOpenThread(item: ThreadActivityItem): void; + onOpenMention(item: MentionActivityItem): void; }) { const present = useIsPresent(); const subscribe = useCallback( @@ -198,6 +215,7 @@ export function ChannelActivityPopover({ ); const snapshot = useSyncExternalStore(subscribe, get, get); const items = snapshot.items ?? []; + const mentions = snapshot.mentions ?? []; const activeAgents = agents ?? []; const agentIds = activeAgents.join(","); const [open, setOpen] = useState(false); @@ -209,9 +227,9 @@ export function ChannelActivityPopover({ NonNullable["actionsRef"]>["current"] >(null); const pressedOpen = useRef(false); - // An opened thread takes focus itself. Base UI returns focus to the trigger - // in a microtask after closing, which can land after, and steal it from, the - // thread. + // An opened thread or mention takes focus itself. Base UI returns focus to + // the trigger in a microtask after closing, which can land after, and steal + // it from, what opened. const handoff = useRef(false); // Hover opens a transient preview. Selecting its row may still take focus. const [preview, setPreview] = useState(false); @@ -246,11 +264,12 @@ export function ChannelActivityPopover({ label?: string; } | null)[]; const keyLabels = publicKeyLabels(activeAgents); - const hasActivity = items.length > 0 || activeAgents.length > 0; + const unread = items.length + mentions.length; + const hasActivity = unread > 0 || activeAgents.length > 0; useEffect(() => { if (!hasActivity && open) actions.current?.close(); }, [hasActivity, open]); - const stale = items.length > 0 && snapshot.freshness === "stale"; + const stale = unread > 0 && snapshot.freshness === "stale"; // Row actions close through Base UI so its hover/press state resets; the // sidebar trigger stays mounted after the navigation they start. const close = () => { @@ -291,12 +310,12 @@ export function ChannelActivityPopover({ handoff.current = false; setNow(Date.now()); } - if (next && items.length) + if (next && unread) void session.profiles .ensure( [ ...new Set( - items.map( + [...mentions, ...items].map( ({ authorId, workflowOwnerId }) => workflowOwnerId ?? authorId, ), @@ -368,6 +387,38 @@ export function ChannelActivityPopover({ })} )} + {mentions.length > 0 && ( +
+

+ Mentions +

+ {mentions.map((mention) => ( + { + handoff.current = true; + close(); + onOpenMention(mention); + }} + read={() => + session.unread.markThrough( + { + kind: "message", + channelId: mention.channelId, + messageId: mention.messageId, + }, + mention.messageId, + ) + } + /> + ))} +
+ )} {items.length > 0 && (

@@ -377,13 +428,24 @@ export function ChannelActivityPopover({ { + onOpen={() => { handoff.current = true; close(); - onOpenThread(selected); + onOpenThread(item); }} + read={() => + session.unread.markThrough( + { + kind: "thread", + channelId: item.channelId, + rootId: item.rootId, + }, + item.latestMessageId, + ) + } /> ))}

diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index 0e9c323554..b80ebdac91 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -127,6 +127,7 @@ function itemProps( selected: undefined, onSelect: vi.fn(), onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; } @@ -275,6 +276,7 @@ it("keeps live unread updates and uses replacement session callbacks across row selected: undefined, onSelect, onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: "Alpha" }); @@ -353,6 +355,7 @@ it("badges one-to-one DM avatars with live presence and sanitizes their media", selected: undefined, onSelect: vi.fn(), onOpenThread: vi.fn(), + onOpenMessage: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: /^Alice$/ }); @@ -448,6 +451,7 @@ it.each(["ContextMenu", "F10"])( selected={undefined} onSelect={onSelect} onOpenThread={vi.fn()} + onOpenMessage={vi.fn()} menuEnabled sectionKey="group:work" onOpenMenu={onOpenMenu} diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 6b2bd546f4..29d3857a45 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -48,6 +48,7 @@ type ItemProps = { selected: string | undefined; onSelect: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; + onOpenMessage: (channelId: string, messageId: string) => void; onOpenWorkingAgent?: ( channelId: string, agent: string, @@ -143,6 +144,7 @@ function ChannelSidebarItemCore({ selected, onSelect, onOpenThread, + onOpenMessage, onOpenWorkingAgent, onOpenAgentActivity, onHideDm, @@ -267,6 +269,9 @@ function ChannelSidebarItemCore({ {...(onOpenWorkingAgent ? { onOpenWorkingAgent } : {})} {...(onOpenAgentActivity ? { onOpenAgentActivity } : {})} onOpenThread={(item) => onOpenThread(item.channelId, item.rootId)} + onOpenMention={(item) => + onOpenMessage(item.channelId, item.messageId) + } trigger={trigger} /> ); diff --git a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx index c398e0ad57..05ae36b258 100644 --- a/src/bundled/channels/ChannelSidebarMenuItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarMenuItem.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../../features/relay/session"; @@ -93,10 +93,25 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side expect(item.querySelector("[data-channel-priority]")).toBeNull(); act(() => { unread = { ...unread, attentionCount: 1 }; + activity = { + ...activity, + mentions: [ + { + channelId: "alpha", + messageId: "mention", + authorId: "peer", + createdAt: 2, + preview: "Ask", + }, + ], + }; publish(); }); expect(label).not.toHaveAttribute("data-ordinary"); + // A mention alone keeps the priority dot, and names itself to assistive tech. expect(item.querySelector("[data-channel-priority]")).toBeInTheDocument(); + expect(item.querySelector("[data-channel-activity]")).toBeNull(); + expect(item).toHaveAccessibleName(/1 unread mention$/); act(() => { activity = { ...activity, @@ -114,9 +129,11 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side }; publish(); }); + // One dot names both, so the mention is not announced twice. expect( - screen.getByRole("img", { name: "1 unread thread" }), - ).toBeInTheDocument(); + screen.getByRole("img", { name: "1 unread mention, 1 unread thread" }), + ).toHaveAttribute("title", "1 unread mention, 1 unread thread"); + expect(within(item).queryByText(/unread mention/)).toBeNull(); expect(item.querySelector("[data-channel-priority]")).toBeNull(); act(() => { agents = { @@ -139,7 +156,7 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side ).toBeInTheDocument(); act(() => { unread = { ...unread, observedCount: 0, attentionCount: 0 }; - activity = { ...activity, items: [] }; + activity = { ...activity, items: [], mentions: [] }; publish(); }); expect(label).not.toHaveAttribute("data-ordinary"); @@ -163,3 +180,75 @@ it("keeps menu choices in sync with ordinary, attention, thread and working side expect(unreadListeners.size).toBe(0); expect(agentListeners.size).toBe(0); }); + +it.each([ + ["an unmuted", false], + ["a muted", true], +])( + "announces the mentions of %s row with no unread thread", + async (_, muted) => { + const owner = createRelaySession(null); + owners.push(owner); + const unread = { + ...owner.session.unread.snapshot({ kind: "channel", channelId: "alpha" }), + observedCount: 2, + attentionCount: 2, + }; + const mention = { + channelId: "alpha", + messageId: "newer", + authorId: "peer", + createdAt: 2, + preview: "Ask", + }; + const activity = { + ...owner.session.unread.activity("alpha"), + items: [], + mentions: [mention, { ...mention, messageId: "older", createdAt: 1 }], + freshness: "stale" as const, + }; + const session = { + ...owner.session, + unread: { + ...owner.session.unread, + snapshot: () => unread, + activity: () => activity, + }, + sidebarPreferences: { + ...owner.session.sidebarPreferences, + snapshot: () => ({ + status: "ready" as const, + data: { + sections: [], + assignments: {}, + starred: [], + muted: muted ? ["alpha"] : [], + }, + }), + }, + }; + render( + + Choose channel + + {}} + /> + + , + ); + const item = await screen.findByRole("menuitem", { name: /^Alpha/ }); + // The priority dot is aria-hidden, and a muted row hides it. + expect(!!item.querySelector("[data-channel-priority]")).toBe(!muted); + expect(item.querySelector("[data-channel-activity]")).toBeNull(); + expect( + within(item).getByText("2 unread mentions; may be out of date"), + ).toHaveClass("sr-only"); + expect(item).toHaveAccessibleName( + /unread messages\. Not an exact total\.\s*2 unread mentions; may be out of date$/, + ); + }, +); diff --git a/src/bundled/channels/DmTypingBadge.test.tsx b/src/bundled/channels/DmTypingBadge.test.tsx index 88c3961365..25ba568c71 100644 --- a/src/bundled/channels/DmTypingBadge.test.tsx +++ b/src/bundled/channels/DmTypingBadge.test.tsx @@ -93,6 +93,7 @@ function fixture() { selected={selected} onSelect={() => {}} onOpenThread={() => {}} + onOpenMessage={() => {}} /> diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index 2a9639845c..1ed9fb4d5d 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -134,7 +134,12 @@ export function UnreadBadge({ const unreadLabel = manual ? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}` : `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`; - const threadLabel = `${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${activity.freshness === "stale" ? "; may be out of date" : ""}`; + const mentionCount = activity.mentions?.length ?? 0; + const stale = activity.freshness === "stale" ? "; may be out of date" : ""; + const mentionLabel = `${mentionCount} unread ${mentionCount === 1 ? "mention" : "mentions"}`; + // Mentions share the thread dot. Without a thread they have only the + // aria-hidden priority dot, or none when muted, so name them on their own. + const activityLabel = `${mentionCount ? `${mentionLabel}, ` : ""}${threadCount} unread ${threadCount === 1 ? "thread" : "threads"}${stale}`; return ( <> {label !== undefined && ( @@ -164,17 +169,20 @@ export function UnreadBadge({ aria-hidden="true" /> )} + {mentionCount > 0 && threadCount === 0 && ( + {`${mentionLabel}${stale}`} + )} {threadCount > 0 && (hideUnreadDots ? ( - {threadLabel} + {activityLabel} ) : ( ))} diff --git a/src/bundled/inbox/InboxPage.test.tsx b/src/bundled/inbox/InboxPage.test.tsx index 490eafa041..331f899fd7 100644 --- a/src/bundled/inbox/InboxPage.test.tsx +++ b/src/bundled/inbox/InboxPage.test.tsx @@ -568,6 +568,8 @@ async function markRead(h: ReturnType, channelId: string) { for (const item of items) if (item.target.kind === "channel") await h.session.unread.prepareChannelRead(channelId)(); + else if (item.timeline) + await h.session.unread.markTimelineRead(channelId, item.messageIds); else for (const step of item.readThrough) await h.session.unread.markThrough(step.target, step.messageId); @@ -580,7 +582,7 @@ it("previews recent conversations and opens a row directly", async () => { render(previewAt(h, close)); await waitFor(() => expect(previewRows()).toHaveLength(3)); expect( - screen.getByText("1 mention · 1 thread · 1 DM unread"), + screen.getByText("3 unread · 1 mention · 1 thread · 1 DM"), ).toBeInTheDocument(); // The newest #Design row opens at its exact message, in its thread when it has one. @@ -619,7 +621,9 @@ it("keeps Unread only to the card and offers recent activity once caught up", as expect(screen.getByRole("switch", { name: "Unread only" })).not.toBeChecked(); await markRead(h, "dm-room"); await waitFor(() => - expect(screen.getByText("1 mention · 1 thread unread")).toBeInTheDocument(), + expect( + screen.getByText("2 unread · 1 mention · 1 thread"), + ).toBeInTheDocument(), ); const unreadOnly = () => screen.getByRole("switch", { name: "Unread only" }); fireEvent.click(unreadOnly()); @@ -641,6 +645,37 @@ it("keeps Unread only to the card and offers recent activity once caught up", as expect(unreadOnly()).not.toBeChecked(); }); +it("leads the card's header with the total and counts a mention only while it is unread", async () => { + const h = fixture({ withSolo: true }); + render(previewAt(h)); + await waitFor(() => expect(previewRows()).toHaveLength(3)); + expect( + screen.getByText("3 unread · 1 mention · 1 thread"), + ).toBeInTheDocument(); + // A Solo chat's flat row has no kind, so only the total counts it. + await markRead(h, "room"); + expect(await screen.findByText("1 unread")).toBeInTheDocument(); + + const reply = (content: string, at: number, tags: string[][] = []) => { + const event = message(h.alice, "room", content, at, [ + ["e", h.root?.id ?? "", "", "reply"], + ...tags, + ]); + h.addEvent(event); + act(() => h.emit([event])); + }; + reply("Can you check this?", 30, [["p", h.viewer.pubkey]]); + expect(await screen.findByText("2 unread · 1 mention")).toBeInTheDocument(); + + // A plain reply after the mention was read is thread activity, as Mentions + // with Unread only would list nothing. + await markRead(h, "room"); + await markRead(h, "solo-room"); + expect(await screen.findByText("Nothing unread")).toBeInTheDocument(); + reply("Following up", 31); + expect(await screen.findByText("1 unread · 1 thread")).toBeInTheDocument(); +}); + it("shows the card's connection and read failures with their recovery", async () => { const offline = fixture(); const view = render(previewAt(offline)); @@ -798,6 +833,7 @@ function fixture( failRoster?: boolean; archivedChannel?: boolean; withDm?: boolean; + withSolo?: boolean; withSenders?: boolean; holdProfiles?: boolean; withWriter?: boolean; @@ -931,6 +967,17 @@ function fixture( message(alice, "dm-room", "A direct reply", 23), ] : []), + ...(options.withSolo + ? [ + roster(relayKey, "solo-room", [viewer.pubkey, alice.pubkey], 10), + metadata(relayKey, "solo-room", "Notes", 10, [ + ["t", "stream"], + ["private"], + ["t", "buzz.solo"], + ]), + message(alice, "solo-room", "A Solo update", 23), + ] + : []), ...(options.withSenders ? [ roster(relayKey, "agent-dm", [viewer.pubkey, agent.pubkey], 10), diff --git a/src/bundled/inbox/InboxPreview.tsx b/src/bundled/inbox/InboxPreview.tsx index 9509dc2c9a..2bf495d809 100644 --- a/src/bundled/inbox/InboxPreview.tsx +++ b/src/bundled/inbox/InboxPreview.tsx @@ -258,13 +258,15 @@ function InboxPreviewCard({ if (failure || document.activeElement !== document.body) return; unreadOnlyControl.current?.focus({ preventScroll: true }); }, [failure]); - // Each conversation counts once, under its most specific kind. + // Each conversation counts once, under its most specific kind, as the + // page's filters with Unread only would list it. A Solo chat's own row has + // no kind, so the total leads. let dms = 0; let mentions = 0; let threads = 0; for (const item of unread) { if (channelOf(item.channelId)?.channelType === "dm") dms++; - else if (item.mentioned) mentions++; + else if (item.mention && hasUnread(item.mention)) mentions++; else if (item.thread) threads++; } const kinds = [ @@ -275,7 +277,7 @@ function InboxPreviewCard({ const summary = loading ? "Checking recent activity…" : unread.length - ? `${kinds.length ? kinds.join(" · ") : unread.length} unread` + ? [`${unread.length} unread`, ...kinds].join(" · ") : checked ? "Nothing unread" : undefined; diff --git a/src/bundled/inbox/InboxRow.tsx b/src/bundled/inbox/InboxRow.tsx index 62fd8f7e52..169f464394 100644 --- a/src/bundled/inbox/InboxRow.tsx +++ b/src/bundled/inbox/InboxRow.tsx @@ -30,6 +30,18 @@ type ResolveName = ( export const hasUnread = (item: InboxItem) => item.unreadCount > 0 || item.manual; +/** What a preview shows instead of a body whose stored edits or deletions are + * not settled yet, or undefined when none of `ids` is incomplete. */ +export const incompletePreview = ( + feed: InboxFeedSnapshot, + ids: readonly (string | undefined)[], +) => + ids.some((id) => id !== undefined && feed.incomplete.includes(id)) + ? feed.status === "error" + ? "Preview unavailable. Retry activity." + : "Preview updating…" + : undefined; + /** Who sent a row and where, as the Inbox page and its sidebar preview name it. */ export function rowIdentity( item: InboxItem, @@ -119,13 +131,8 @@ export function InboxRowButton({ unread={unread} previewId={previewId} preview={ - [item.previewMessageId, ...item.messageIds].some( - (id) => id !== undefined && feed.incomplete.includes(id), - ) - ? feed.status === "error" - ? "Preview unavailable. Retry activity." - : "Preview updating…" - : messagePreview(item.preview) + incompletePreview(feed, [item.previewMessageId, ...item.messageIds]) ?? + messagePreview(item.preview) } /> ); diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index 8cb9ecccdb..ba542c45d8 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -812,6 +812,70 @@ it("closes a removed DM's menu and focuses its neighbor before the exit ends", a expect(bob).toHaveFocus(); }); +it("opens an Activity mention at its message, with no thread root", async () => { + const message = "e".repeat(64); + const h = fixture(); + const mention = { + channelId: "beta", + messageId: message, + authorId: "b".repeat(64), + preview: "Can you sign off?", + createdAt: 1, + }; + // Stable per channel, as external-store snapshots must be. + const snapshots = new Map( + ["alpha", "beta", "gamma"].map((id) => [ + id, + { + channelId: id, + items: [], + mentions: id === "beta" ? [mention] : [], + coverage: "observed" as const, + freshness: "observed" as const, + }, + ]), + ); + const unread = h.session.unread; + h.session.unread = { + ...unread, + activity: (id) => snapshots.get(id) ?? unread.activity(id), + subscribeActivity: () => () => {}, + }; + // The sidebar reads back the attempt it started to hand off focus return. + let opened: unknown; + vi.mocked(h.navigator.open).mockImplementation(async (next) => { + opened = next; + return { status: "opened" }; + }); + Object.assign(h.navigator, { + snapshot: () => ({ + attempt: { + id: "visit", + entry: { id: "entry", target: opened }, + signal: new AbortController().signal, + }, + }), + }); + render(h.view("alpha")); + const beta = await screen.findByRole("button", { name: /^beta/ }); + beta.focus(); + await userEvent.keyboard("{Enter}"); + const popup = await screen.findByRole("dialog", { name: "Activity in beta" }); + await userEvent.click( + within(popup).getByRole("button", { name: /^Open unread mention from/ }), + ); + expect(h.navigator.open).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: "conversation", + channelId: "beta", + messageId: message, + }), + ); + expect(vi.mocked(h.navigator.open).mock.lastCall?.[0]).not.toHaveProperty( + "threadRootId", + ); +}); + it("copies a channel link that opens the same channel", async () => { const writeText = vi .fn() diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 14302dd469..114fb111ca 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -1544,6 +1544,7 @@ export function ChannelInventory({ selected={composingMessage ? undefined : selected} onSelect={select} onOpenThread={openActivityThread} + onOpenMessage={openActivityMessage} onOpenWorkingAgent={openWorkingAgent} onOpenAgentActivity={openAgentActivity} menuEnabled diff --git a/src/features/channel-navigation/SoloInventory.test.tsx b/src/features/channel-navigation/SoloInventory.test.tsx index 21431962b3..4a1bcf87b3 100644 --- a/src/features/channel-navigation/SoloInventory.test.tsx +++ b/src/features/channel-navigation/SoloInventory.test.tsx @@ -162,6 +162,43 @@ it("offers unread thread navigation and mark-read through the shared activity po }), ); }); +it("opens an unread mention from the shared activity popover at its message, with no thread root", async () => { + const f = fixture(); + const user = userEvent.setup(); + const message = "e".repeat(64); + f.threads({ + channelId: id, + items: [], + mentions: [ + { + channelId: id, + messageId: message, + authorId: agent, + preview: "Can you sign off?", + createdAt: 1, + }, + ], + coverage: "observed", + freshness: "observed", + }); + screen.getByRole("button", { name: /^Planning/ }).focus(); + await user.keyboard("{Enter}"); + await user.click( + within( + screen.getByRole("dialog", { name: "Activity in Planning" }), + ).getByRole("button", { + name: "Open unread mention from Owned Agent: Can you sign off?", + }), + ); + expect(f.open).toHaveBeenLastCalledWith( + expect.objectContaining({ + kind: "conversation", + channelId: id, + messageId: message, + }), + ); + expect(f.open.mock.lastCall?.[0]).not.toHaveProperty("threadRootId"); +}); it("opens a typing-only agent's profile activity in its Solo chat", async () => { const f = fixture(); f.typing([{ channelId: id, pubkey: agent }]); diff --git a/src/features/channel-navigation/SoloInventory.tsx b/src/features/channel-navigation/SoloInventory.tsx index 33fcb20d67..b679a8c4eb 100644 --- a/src/features/channel-navigation/SoloInventory.tsx +++ b/src/features/channel-navigation/SoloInventory.tsx @@ -187,6 +187,9 @@ export function SoloInventory({ onSelect={() => void open(item.id)} onRename={rename} onOpenThread={(thread) => openMessage(thread.channelId, thread.rootId)} + onOpenMention={(mention) => + openMessage(mention.channelId, mention.messageId) + } onOpenWorkingAgent={(id, _agent, messageId) => { if (messageId) openMessage(id, messageId); else void open(id); @@ -393,6 +396,7 @@ function SoloConversationRow({ onSelect, onRename, onOpenThread, + onOpenMention, onOpenWorkingAgent, onOpenAgentActivity, }: { @@ -409,7 +413,10 @@ function SoloConversationRow({ onRename: (() => boolean) | undefined; } & Pick< ComponentProps, - "onOpenThread" | "onOpenWorkingAgent" | "onOpenAgentActivity" + | "onOpenThread" + | "onOpenMention" + | "onOpenWorkingAgent" + | "onOpenAgentActivity" >) { const activation = useSoloRowActivation( navigation, @@ -433,6 +440,7 @@ function SoloConversationRow({ agents={agents} agentProfiles={agentProfiles} onOpenThread={onOpenThread} + onOpenMention={onOpenMention} {...(onOpenWorkingAgent ? { onOpenWorkingAgent } : {})} {...(onOpenAgentActivity ? { onOpenAgentActivity } : {})} trigger={ diff --git a/src/features/messages/use-reading.test.ts b/src/features/messages/use-reading.test.ts index 157b46528b..9e69e8df78 100644 --- a/src/features/messages/use-reading.test.ts +++ b/src/features/messages/use-reading.test.ts @@ -199,6 +199,24 @@ it("reports only fully visible settled evidence after dwell, not mounted oversca vi.advanceTimersByTime(1); expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); }); +it("a row within a pixel of the viewport's edge is fully visible; one clipped further is not", () => { + const h = setup(); + // Scroll offsets snap to whole pixels but rows sit at fractional offsets, so + // an exact reveal can leave its row a sliver above the scroller's top. + h.setRows([ + row("revealed", -0.047, 40), + row("above", -1.5, 40), + row("last", 460, 500.5), + row("below", 460, 501.5), + ]); + h.mutation(); + vi.advanceTimersToNextFrame(); // Row changes are measured in the next frame. + vi.advanceTimersByTime(300); + expect(h.dwelled()?.observe).toHaveBeenCalledExactlyOnceWith([ + "revealed", + "last", + ]); +}); it("a burst of scrolls and row changes cancels dwell at once but measures rows once, in the next frame", () => { const h = setup(); const measured = vi.spyOn(h.element, "getBoundingClientRect"); diff --git a/src/features/messages/use-reading.ts b/src/features/messages/use-reading.ts index b39ed12262..4dd7d8643f 100644 --- a/src/features/messages/use-reading.ts +++ b/src/features/messages/use-reading.ts @@ -118,6 +118,8 @@ export function useReading({ for (const observed of observing) observed.dispose(); observing.clear(); } + // Within a pixel: scroll offsets snap to whole pixels but rows sit at + // fractional ones, so an exact reveal can leave its row a sliver past an edge. function shown(row: HTMLElement, viewport: DOMRect) { const bounds = row.getBoundingClientRect(); return ( @@ -125,10 +127,10 @@ export function useReading({ row.dataset.huddleRow === undefined && bounds.height > 0 && bounds.width > 0 && - bounds.top >= Math.max(viewport.top, 0) && - bounds.bottom <= Math.min(viewport.bottom, window.innerHeight) && - bounds.left >= Math.max(viewport.left, 0) && - bounds.right <= Math.min(viewport.right, window.innerWidth) + bounds.top >= Math.max(viewport.top, 0) - 1 && + bounds.bottom <= Math.min(viewport.bottom, window.innerHeight) + 1 && + bounds.left >= Math.max(viewport.left, 0) - 1 && + bounds.right <= Math.min(viewport.right, window.innerWidth) + 1 ); } function visibleIds() { diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index 2fbb6f54d6..cc5012ca00 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -840,6 +840,83 @@ it("resolves every activity item through its own channel hierarchy", () => { expect(h.session.unread.activity("other").items).toEqual([]); }); +it("lists unread top-level mentions apart from thread activity, never for a DM or a broadcast", async () => { + const h = setup(); + h.grant("room"); + h.grant("dm"); + h.emit([metadata(h.relay, "dm", "DM", 11, [["t", "dm"]])]); + const tag = [["p", h.viewer.pubkey]]; + const root = message(h.viewer, "room", "My thread", 20); + const older = message(h.alice, "room", "First ask", 21, tag); + const reply = message(h.alice, "room", "In your thread", 22, [ + ["e", root.id, "", "reply"], + ...tag, + ]); + const broadcast = message(h.alice, "room", "Everyone", 23, [ + ["broadcast", "1"], + ]); + h.emit([root, older, reply, broadcast]); + const changed = vi.fn(); + h.session.unread.subscribeActivity("room", changed); + const newer = message(h.alice, "room", "Second ask", 24, tag); + h.emit([newer, message(h.alice, "dm", "Direct ask", 25, tag)]); + + expect(changed).toHaveBeenCalled(); + // The reply's mention stays with its thread. + expect(h.session.unread.activity("room")).toMatchObject({ + items: [{ rootId: root.id, latestMessageId: reply.id }], + mentions: [ + { + channelId: "room", + messageId: newer.id, + authorId: h.alice.pubkey, + createdAt: 24, + preview: "Second ask", + }, + { messageId: older.id, createdAt: 21, preview: "First ask" }, + ], + }); + expect(h.session.unread.activity("dm").mentions).toEqual([]); + + await h.session.unread.markThrough( + { kind: "message", channelId: "room", messageId: newer.id }, + newer.id, + ); + expect(h.session.unread.activity("room").mentions).toEqual([ + expect.objectContaining({ messageId: older.id }), + ]); +}); + +it("lists a top-level mention that cites or quotes a message, and keeps the snapshot through an unrelated publish", () => { + const h = setup(); + h.grant("room"); + const tag = [["p", h.viewer.pubkey]]; + const root = message(h.viewer, "room", "My thread", 20); + // Neither tag marks a reply, so both stay top-level. + const cited = message(h.alice, "room", "Cites it", 21, [ + ["e", root.id], + ...tag, + ]); + const quoted = message(h.alice, "room", "Quotes it", 22, [ + ["e", root.id, "", "mention"], + ...tag, + ]); + h.emit([root, cited, quoted]); + const activity = h.session.unread.activity("room"); + expect(activity).toMatchObject({ + items: [], + mentions: [{ messageId: quoted.id }, { messageId: cited.id }], + }); + + const changed = vi.fn(); + h.session.unread.subscribeActivity("room", changed); + h.emit([message(h.alice, "room", "Unrelated", 23)]); + // The publish landed, and left Activity alone. + expect(h.snapshot().observedCount).toBe(3); + expect(changed).not.toHaveBeenCalled(); + expect(h.session.unread.activity("room")).toBe(activity); +}); + it("distinguishes unknown thread-activity evidence from observed evidence", () => { const h = setup(); h.grant("room"); @@ -4346,6 +4423,7 @@ it("agent-audience messages never count, earn attention, list in Inbox or activi attentionCount: 0, }); expect(unread.activity("room").items).toEqual([]); + expect(unread.activity("room").mentions).toEqual([]); expect(unread.inbox().items).toEqual([]); for (const [channelId, row] of [ ["room", progress], diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index f20ab55d7c..c2f5e33e57 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -76,10 +76,22 @@ export type ThreadActivityItem = Readonly<{ preview: string; unreadCount: number; }>; +export type MentionActivityItem = Readonly<{ + channelId: string; + messageId: string; + authorId: string; + workflowOwnerId?: string | undefined; + createdAt: number; + preview: string; +}>; export type ThreadActivitySnapshot = Readonly<{ channelId: string; /** null means activity evidence is unknown or access is denied. */ items: readonly ThreadActivityItem[] | null; + /** Unread top-level messages that mention the viewer, newest first. A + * reply's mention is listed under its thread instead, and in neither while + * its thread root is unresolved. */ + mentions?: readonly MentionActivityItem[]; coverage: "unknown" | "observed"; freshness: "unknown" | "observed" | "stale"; error?: string | undefined; @@ -1074,11 +1086,37 @@ export function createUnread({ const state = reads.state(); const dm = isDm(channelId); const grouped = new Map(); - // Only unread thread activity is presented; most publishes have none. + const mentions: MentionActivityItem[] = []; + // Only unread activity is presented; most publishes have none. let presented: Map | undefined; + const previewOf = (event: RelayEvent) => { + presented ??= new Map( + folded(channelId).map((message) => [message.id, message.content]), + ); + return presented.get(event.id) ?? event.content; + }; for (const evidence of byChannel.get(channelId) ?? []) { const { event, rootId } = evidence; want(evidence, dm); + // A top-level mention is listed by message. A DM never has one, and a + // broadcast alone is not one. + if (!evidence.parentId) { + if ( + category(evidence, dm) === "mention" && + isUnread(evidence, state, dm) + ) + mentions.push( + Object.freeze({ + channelId, + messageId: event.id, + authorId: event.pubkey, + workflowOwnerId: workflowOwner(event, workflowAuthority), + createdAt: event.created_at, + preview: previewOf(event), + }), + ); + continue; + } if ( !rootId || !activityEvidence(evidence) || @@ -1086,10 +1124,7 @@ export function createUnread({ ) continue; const current = grouped.get(rootId); - presented ??= new Map( - folded(channelId).map((message) => [message.id, message.content]), - ); - const preview = presented.get(event.id) ?? event.content; + const preview = previewOf(event); if (!current) { grouped.set( rootId, @@ -1135,6 +1170,12 @@ export function createUnread({ a.latestMessageId.localeCompare(b.latestMessageId), ), ), + mentions: Object.freeze( + mentions.sort( + (a, b) => + b.createdAt - a.createdAt || a.messageId.localeCompare(b.messageId), + ), + ), coverage: "observed", freshness, ...(error ? { error } : {}), @@ -1162,7 +1203,18 @@ export function createUnread({ item.preview === other.preview && item.unreadCount === other.unreadCount ); - }))); + }))) && + (a.mentions?.length ?? 0) === (b.mentions?.length ?? 0) && + (a.mentions ?? []).every((mention, index) => { + const other = b.mentions?.[index]; + return ( + mention.messageId === other?.messageId && + mention.authorId === other.authorId && + mention.workflowOwnerId === other.workflowOwnerId && + mention.createdAt === other.createdAt && + mention.preview === other.preview + ); + }); function activity(channelId: string) { const previous = activitySnapshots.get(channelId); if (previous && !activityDirty.delete(channelId)) return previous; diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index 513c91fc37..3bc9816513 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -7,6 +7,7 @@ import { readJournal, releaseReadingFocus, } from "./reading.mjs"; +import { openPage } from "./navigation.mjs"; import { channelUnread } from "./unread-state.mjs"; test.use({ @@ -1160,3 +1161,156 @@ test("a live reply in a main-pane thread stays unread while focus is in the side await page.clock.runFor(1000); expect(await unread(x.id)).toBeGreaterThan(0); }); + +test.describe("top-level mention activity", () => { + test.use({ historyCounts: { alpha: 20, beta: 0 } }); + + // Browser-only: hover opening, the exact opening in the timeline and the + // visibility read depend on real layout and focus. + test("a mention-only row opens Activity, and opening its mention reads it", async ({ + page, + app, + }) => { + await open(page, app); + // Load Beta's window first: an exact opening that arrives while the window + // is still settling opens the message as its own tab instead. + const beta = page.locator('button[data-channel-id="beta"]'); + await beta.click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + await page.locator('button[data-channel-id="alpha"]').click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + const mention = app.append( + "primary", + "beta", + "Can you sign off on Beta?", + true, + false, + undefined, + undefined, + [["p", app.viewer]], + ); + await openPage(page, "Activity"); + const inbox = page.getByRole("region", { name: "Activity", exact: true }); + const row = inbox + .getByRole("list", { name: "Activity", exact: true }) + .getByRole("listitem") + .filter({ has: page.getByRole("button", { name: / in #Beta$/ }) }); + await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); + await openPage(page, "Messages"); + // A mention alone keeps the priority dot. + await expect(beta.locator("[data-channel-priority]")).toBeVisible(); + await expect(beta.locator("[data-channel-activity]")).toHaveCount(0); + await beta.hover(); + const popover = page.getByRole("dialog", { name: "Activity in Beta" }); + const item = popover + .getByRole("region", { name: "Mentions" }) + .getByRole("button", { + name: /^Open unread mention from .+: Can you sign off on Beta\?$/, + }); + await expect(item).toBeVisible(); + await expect( + popover.getByRole("region", { name: "Unread threads" }), + ).toHaveCount(0); + await item.click(); + const message = page.locator( + `[data-channel-timeline] [data-message-id="${mention.id}"]`, + ); + await expect(message).toBeInViewport(); + await expect(message).toBeFocused(); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("beta", id) + .unread, + mention.id, + ), + ) + .toBe(false); + await openPage(page, "Activity"); + await expect(row).toBeVisible(); + await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); + }); + + // Browser-only: the reveal's fractional scroll position and the visibility + // read depend on real layout. + test("opening a mention above the bottom from Activity reads it there", async ({ + page, + app, + }) => { + await open(page, app); + // Leave Alpha, its window loaded, so its new messages arrive unseen. + await page.locator('button[data-channel-id="beta"]').click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + const mention = app.append( + "primary", + "alpha", + "Can you sign off on Alpha?", + true, + false, + undefined, + undefined, + [["p", app.viewer]], + ); + // Enough later messages that revealing the mention cannot reach the + // bottom, whose catch-up would read it anyway. + for (let index = 0; index < 40; index++) + app.append("primary", "alpha", `Later update ${index}`, true, false); + await openPage(page, "Activity"); + const row = page + .getByRole("region", { name: "Activity", exact: true }) + .getByRole("list", { name: "Activity", exact: true }) + .getByRole("listitem") + .filter({ hasText: "Can you sign off on Alpha?" }); + await expect(row.getByRole("img", { name: "Unread" })).toBeVisible(); + await openPage(page, "Messages"); + const alpha = page.locator('button[data-channel-id="alpha"]'); + await alpha.hover(); + const popover = page.getByRole("dialog", { name: "Activity in Alpha" }); + const mentions = popover.getByRole("region", { name: "Mentions" }); + await mentions + .getByRole("button", { + name: /^Open unread mention from .+: Can you sign off on Alpha\?$/, + }) + .click(); + const message = page.locator( + `[data-channel-timeline] [data-message-id="${mention.id}"]`, + ); + await expect(message).toBeInViewport(); + await expect(message).toBeFocused(); + expect( + await page + .getByRole("region", { name: "Channel message history" }) + .evaluate( + (element) => + element.scrollHeight - element.clientHeight - element.scrollTop, + ), + "the reveal stops above the bottom", + ).toBeGreaterThan(1); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("alpha", id) + .unread, + mention.id, + ), + ) + .toBe(false); + // Alpha's unread threads keep Activity open; the mention has left it. + await alpha.hover(); + await expect( + popover.getByRole("region", { name: "Unread threads" }), + ).toBeVisible(); + await expect(mentions).toHaveCount(0); + await openPage(page, "Activity"); + await expect(row).toBeVisible(); + await expect(row.getByRole("img", { name: "Unread" })).toHaveCount(0); + }); +}); diff --git a/tests/fixtures/nav-thinking.tsx b/tests/fixtures/nav-thinking.tsx index c8762aa283..9108b45518 100644 --- a/tests/fixtures/nav-thinking.tsx +++ b/tests/fixtures/nav-thinking.tsx @@ -264,6 +264,7 @@ function Preview() { selected={selected} onSelect={selectChannel} onOpenThread={() => {}} + onOpenMessage={() => {}} onOpenWorkingAgent={(channelId, agent) => { selectChannel(channelId); setDetail({ agent, destination: "thread" }); @@ -289,6 +290,7 @@ function Preview() { selected={selected} onSelect={selectChannel} onOpenThread={() => {}} + onOpenMessage={() => {}} onOpenWorkingAgent={(channelId, agent) => { selectChannel(channelId); setDetail({ agent, destination: "thread" }); diff --git a/tests/fixtures/user-status.tsx b/tests/fixtures/user-status.tsx index 24eb01d257..e2056b2810 100644 --- a/tests/fixtures/user-status.tsx +++ b/tests/fixtures/user-status.tsx @@ -180,6 +180,7 @@ createRoot(root).render( selected={undefined} onSelect={() => {}} onOpenThread={() => {}} + onOpenMessage={() => {}} />