From b478a55a2f704f7f1f31cca5475f53357574cfff Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 13 Sep 2026 09:09:15 -0400 Subject: [PATCH 01/15] Add rich links and clickable message previews Signed-off-by: klopez4212 --- crates/plugin-manager/src/lib.rs | 2 + crates/plugin-manager/tests/management.rs | 1 + docs/plugin-architecture.md | 51 ++++- src/app/pages.integration.test.mjs | 13 ++ src/bundled/channels/ChannelsPage.tsx | 62 +++++-- src/bundled/index.ts | 3 + src/bundled/link-lab/LinkLab.module.css | 51 +++++ src/bundled/link-lab/README.md | 46 +++++ src/bundled/link-lab/index.tsx | 166 +++++++++++++++++ src/bundled/link-lab/manifest.json | 1 + src/bundled/links/InlineLink.test.tsx | 124 +++++++++++++ src/bundled/links/InlineLink.tsx | 162 ++++++++++++++++ src/bundled/links/index.tsx | 14 ++ src/bundled/links/manifest.json | 1 + src/features/conversation/BuzzLinkPreview.tsx | 135 ++++++++++++++ src/features/conversation/LinkLabelContext.ts | 7 + .../conversation/LinkPreview.module.css | 71 +++++++ .../conversation/MessageLink.test.tsx | 44 +++++ src/features/conversation/MessageLink.tsx | 166 +++++++++++++++++ src/features/conversation/contracts.ts | 9 + .../conversation/message-preview-text.test.ts | 23 +++ .../conversation/message-preview-text.ts | 8 + src/features/conversation/service.test.tsx | 36 ++++ src/features/conversation/service.tsx | 20 +- src/features/messages/ChannelTimeline.tsx | 2 + .../messages/MessageMarkdown.test.tsx | 80 +++++++- src/features/messages/MessageMarkdown.tsx | 162 +++++++++++----- src/features/messages/MessageRow.test.tsx | 55 ++++++ src/features/messages/MessageRow.tsx | 10 + src/features/messages/Messages.module.css | 8 +- src/features/messages/ReferenceText.tsx | 136 ++++++++++++++ src/features/messages/ThreadPanel.test.tsx | 91 ++++++++- src/features/messages/ThreadPanel.tsx | 77 +++++++- .../messages/message-link-parts.test.ts | 146 +++++++++++++++ src/features/messages/message-link-parts.ts | 115 ++++++++++++ .../messages/message-references.test.ts | 69 +++++++ src/features/messages/message-references.ts | 70 +++++++ src/features/navigation/buzz-links.test.ts | 67 +++++++ src/features/navigation/buzz-links.ts | 95 ++++++++++ src/plugins/author.ts | 1 + src/shared/InlineReference.module.css | 47 +++++ .../design-system/styles/components.css | 9 +- src/shared/design-system/ui/PreviewCard.tsx | 70 ++++++- src/shared/design-system/ui/registry.ts | 5 +- src/shared/relative-timestamp.test.ts | 17 ++ src/shared/relative-timestamp.ts | 14 ++ tests/browser/buzz-links.spec.mjs | 150 +++++++++++++++ tests/fixtures/link-lab.css | 4 + tests/fixtures/link-lab.html | 2 + tests/fixtures/link-lab.tsx | 42 +++++ tests/fixtures/link-messages.html | 1 + tests/fixtures/link-messages.tsx | 174 ++++++++++++++++++ 52 files changed, 2830 insertions(+), 105 deletions(-) create mode 100644 src/bundled/link-lab/LinkLab.module.css create mode 100644 src/bundled/link-lab/README.md create mode 100644 src/bundled/link-lab/index.tsx create mode 100644 src/bundled/link-lab/manifest.json create mode 100644 src/bundled/links/InlineLink.test.tsx create mode 100644 src/bundled/links/InlineLink.tsx create mode 100644 src/bundled/links/index.tsx create mode 100644 src/bundled/links/manifest.json create mode 100644 src/features/conversation/BuzzLinkPreview.tsx create mode 100644 src/features/conversation/LinkLabelContext.ts create mode 100644 src/features/conversation/LinkPreview.module.css create mode 100644 src/features/conversation/MessageLink.test.tsx create mode 100644 src/features/conversation/MessageLink.tsx create mode 100644 src/features/conversation/message-preview-text.test.ts create mode 100644 src/features/conversation/message-preview-text.ts create mode 100644 src/features/messages/ReferenceText.tsx create mode 100644 src/features/messages/message-link-parts.test.ts create mode 100644 src/features/messages/message-link-parts.ts create mode 100644 src/features/messages/message-references.test.ts create mode 100644 src/features/messages/message-references.ts create mode 100644 src/features/navigation/buzz-links.test.ts create mode 100644 src/features/navigation/buzz-links.ts create mode 100644 src/shared/InlineReference.module.css create mode 100644 src/shared/relative-timestamp.test.ts create mode 100644 src/shared/relative-timestamp.ts create mode 100644 tests/browser/buzz-links.spec.mjs create mode 100644 tests/fixtures/link-lab.css create mode 100644 tests/fixtures/link-lab.html create mode 100644 tests/fixtures/link-lab.tsx create mode 100644 tests/fixtures/link-messages.html create mode 100644 tests/fixtures/link-messages.tsx diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 63886dd3..a2175c31 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -55,6 +55,8 @@ pub fn bundled_manifests() -> Vec { .expect("terminal manifest"), serde_json::from_str(include_str!("../../../src/bundled/profiles/manifest.json")) .expect("valid bundled Profiles manifest"), + serde_json::from_str(include_str!("../../../src/bundled/links/manifest.json")) + .expect("links manifest"), serde_json::from_str(include_str!("../../../src/bundled/mentions/manifest.json")) .expect("mentions manifest"), serde_json::from_str(include_str!("../../../src/bundled/emoji/manifest.json")) diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 678b7f06..15dbc1e5 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -278,6 +278,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.agents", "buzz.emoji", "buzz.mentions", + "buzz.links", ] { assert!( manager diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 70c2b641..f984388b 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -92,7 +92,7 @@ render failures and remounts on target or revision changes. Unloading a plugin removes its contributions and closes its panel. Other pages can use these same contracts with their own layout and local navigation. -The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects +The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles, Terminal and Links. Projects is an enabled-by-default scaffold with only a centered title and no relay dependency. GitHub recognizes repository, pull request, issue, and commit URLs and loads public object details on demand. @@ -281,12 +281,16 @@ into versioned route parameters. These are host-matched preview types through Browser `#buzz=` addresses and session history support reload and Back/Forward. `targetLink`/`parseTargetLink` define a `buzz://open` locator codec that omits the -sender's viewer; `bindSharedTarget` pins it for an admitted recipient. **This slice -does not install native OS deep-link or notification-click ingress, migrate legacy -Buzz links, or locate/reveal older messages and threads.** Message-addressed -conversation targets explicitly fail as unsupported rather than claiming success -at the channel head. Those ingresses/reveal adapters must use the same validated -target and completion lifecycle when implemented. +sender's viewer; `bindSharedTarget` pins it for an admitted recipient. Messages also +recognize legacy `buzz://channel/` and +`buzz://message?channel=&id=&thread=` links. Legacy links +use the receiving conversation's community and viewer; shared versioned links +retain their community and use the recipient's viewer. Both pass through existing +navigation admission and session ownership checks. Message targets open their +verified thread, reveal the exact message after bounded history loading, and only +then acknowledge navigation. Supplied root hints do not override verified events. +Missing or unavailable messages report failure. Native OS deep-link and +notification-click ingress remain outside this slice. Drafts, reading geometry and sidebar view intent remain domain-owned, outside visit history. Saved sidebar preferences live in the relay session, not in the @@ -294,6 +298,39 @@ mounted page; see [sidebar ownership](channels.md#ownership). ## Conversation contributions +`registerLink({ id, title, matches, className?, component })` contributes optional +presentation for links already recognized by messages. The host retains the anchor, +destination, new-tab/modifier behavior and panel activation. Components receive +`{ url }` and render non-interactive inline content inside that anchor. They must +not nest links or buttons. The first active matching renderer wins; throwing +matchers are skipped. A render failure or plugin removal restores the ordinary +link, including its styling. Registration follows the existing plugin lifetime. + +The bundled Links plugin uses blue text, a blue fill only on hover, 2px padding +4px corners, and service icons for GitHub, Google Drive, Figma, Notion, Slack, +Dropbox, OneDrive, GitLab, YouTube, Loom, Zoom and Teams. Google Docs, Sheets and +Slides use distinct file-type icons; unknown websites use a globe. Host matching +does not fetch metadata or infer a service from names in paths or query strings. +It does not fetch titles. Messages currently recognize +credential-free HTTPS and supported Buzz links. Markdown labels preserve their +formatting, escaped pasted wrappers are normalized outside code, and paired `<…>` +autolink wrappers are hidden in display. Buzz links use known channel names with corresponding icons, falling back +to Channel, Message or Thread when that name is unavailable in the current community; +the full destination remains on the anchor. Buzz activation stays inside the host, +including modifier/middle clicks, even when the optional Links plugin is disabled. +Unsupported Buzz formats remain plain text. The host-matched author preview exports `LinkRenderer`; older hosts do not +provide `registerLink`. + +Message hover/focus previews are host-owned. The entire card is a keyboard-accessible +link to the same destination, routed through the same host navigation handler. Opening a preview allocates the current +session's bounded thread reader; closing disposes it. The reader supplies verified +message content, author and timestamp and preserves edit/deletion/access handling. +Previewing never acknowledges reading or switches community. The host offers a +resolved label to the bundled presentation via `LinkLabelContext`; no author API +contract or relay protocol changes are required. Known channel references and +unambiguous signed person/agent mentions share the inline hover styling. Names in +ordinary prose never create notification intent or establish an identity. + The conversation preview exposes top-level `registerTool`, `registerCompletion` and `registerInline` methods and stable `conversation.ui.Composer` / `.Message` components. Generated type-only `@buzz/author` declarations support the independent Composer Lab example. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index f9b0f0f5..529c0a1e 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -34,6 +34,19 @@ test("the app runtime exposes ready bundled pages and removes them on disable", assert.equal(services.conversation.tools.snapshot().length, 2), ); assert.equal(services.conversation.inline.snapshot().length, 1); + await vi.waitFor(() => + assert.equal(services.conversation.links.snapshot().length, 1), + ); + assert.equal( + services.conversation.links.snapshot()[0].pluginId, + "buzz.links", + ); + await services.plugins.change("disable", "buzz.links"); + assert.equal(services.conversation.links.snapshot().length, 0); + await services.plugins.change("enable", "buzz.links"); + await vi.waitFor(() => + assert.equal(services.conversation.links.snapshot().length, 1), + ); await services.plugins.change("disable", "buzz.emoji"); assert.deepEqual( services.conversation.tools.snapshot().map((tool) => tool.pluginId), diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index fe38e0f0..f59222c3 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,6 +1,7 @@ import { useChannelPanels } from "./useChannelPanels"; import type { PageNavigation } from "../../features/navigation/service"; import type { Navigation } from "../../features/navigation/controller"; +import { buzzLinkTarget } from "../../features/navigation/buzz-links"; import { UnreadBadge, UnreadOptions } from "./UnreadBadge"; import { SidebarUnread } from "./SidebarUnread"; import type { ConversationExtensions } from "../../features/conversation/contracts"; @@ -64,15 +65,7 @@ export function ChannelsPage({ const sessionNavigation = navigation?.forSession(relay, session); useEffect(() => { if (!navigation || !sessionNavigation) return; - if ( - navigation.target.kind === "conversation" && - navigation.target.messageId - ) - sessionNavigation.complete({ status: "failed", reason: "unavailable" }); - else if ( - session.status === "disconnected" && - navigation.target.kind === "page" - ) + if (session.status === "disconnected" && navigation.target.kind === "page") sessionNavigation.complete({ status: "opened" }); else if (session.status === "error") sessionNavigation.complete({ status: "failed", reason: "unavailable" }); @@ -215,7 +208,16 @@ function ChannelWorkspace({ }); } }, [requestedChannel, current, list.status, navigation, viewer, scope]); - const showingThread = thread?.channelId === current?.id ? thread : undefined; + const requestedMessage = + navigation?.target.kind === "conversation" + ? navigation.target.messageId + : undefined; + const showingThread = + requestedMessage && current + ? { channelId: current.id, messageId: requestedMessage } + : thread?.channelId === current?.id + ? thread + : undefined; useEffect(() => { if (thread && !showingThread) setThread(undefined); }, [thread, showingThread]); @@ -250,6 +252,15 @@ function ChannelWorkspace({ const openThread = useCallback( (messageId: string) => { if (!current) return; + if ( + requestedMessage && + navigator && + navigation?.target.kind === "conversation" + ) { + const { threadRootId: _thread, ...target } = navigation.target; + void navigator.open({ ...target, messageId }); + return; + } threadTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement @@ -257,12 +268,24 @@ function ChannelWorkspace({ setThread({ channelId: current.id, messageId }); open(undefined); }, - [current, open], + [current, open, requestedMessage, navigator, navigation], ); const closeThread = useCallback(() => { setThread(undefined); + if ( + requestedMessage && + navigator && + navigation?.target.kind === "conversation" + ) { + const { + messageId: _message, + threadRootId: _thread, + ...channel + } = navigation.target; + void navigator.open(channel); + } if (threadTrigger.current?.isConnected) threadTrigger.current.focus(); - }, []); + }, [requestedMessage, navigator, navigation]); const panelTrigger = useRef(null); const close = useCallback(() => { open(undefined); @@ -284,6 +307,18 @@ function ChannelWorkspace({ ); const openLink = useCallback( (url: string) => { + if (url.startsWith("buzz://")) { + if (!navigator || !viewer) return false; + const target = buzzLinkTarget(url, { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }); + if (!target) return false; + setThread(undefined); + open(undefined); + void navigator.open(target); + return true; + } const candidate = panels.resolve(url); if (current && candidate) { panelTrigger.current = @@ -300,7 +335,7 @@ function ChannelWorkspace({ } return false; }, - [panels, current, open], + [panels, current, open, navigator, viewer, scope], ); const panelActive = () => { const connection = relay.snapshot(); @@ -563,6 +598,7 @@ function ChannelWorkspace({ channelName={current?.name ?? ""} channelId={showingThread.channelId} messageId={showingThread.messageId} + navigation={requestedMessage ? navigation : undefined} close={closeThread} onOpenLink={openLink} canOpenLink={canOpenLink} diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 42a731cd..a5a57770 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -19,11 +19,14 @@ import * as bestie from "./bestie"; import projectsManifest from "./projects/manifest.json"; import * as projects from "./projects"; import type { BundledPlugin } from "../plugins/manager"; +import linksManifest from "./links/manifest.json"; +import * as links from "./links"; export const bundledPlugins: readonly BundledPlugin[] = [ { manifest: { ...activityManifest, apiVersion: 1 }, module: activity }, { manifest: { ...terminalManifest, apiVersion: 1 }, module: terminal }, { manifest: { ...profilesManifest, apiVersion: 1 }, module: profiles }, + { manifest: { ...linksManifest, apiVersion: 1 }, module: links }, { manifest: { ...mentionsManifest, apiVersion: 1 }, module: mentions }, { manifest: { ...emojiManifest, apiVersion: 1 }, module: emoji }, { manifest: { ...channelsManifest, apiVersion: 1 }, module: channels }, diff --git a/src/bundled/link-lab/LinkLab.module.css b/src/bundled/link-lab/LinkLab.module.css new file mode 100644 index 00000000..6855a344 --- /dev/null +++ b/src/bundled/link-lab/LinkLab.module.css @@ -0,0 +1,51 @@ +.page { + max-width: 760px; + margin: 0 auto; + padding: 48px 24px; + font-family: var(--font-sans); + color: var(--text-primary); +} + +.page h1 { + margin: 0 0 12px; +} + +.intro, +.status { + color: var(--text-secondary); +} + +.samples { + margin-block: 40px; + display: grid; + gap: 32px; +} + +.samples p { + margin: 8px 0 0; + font-size: var(--text-body-lg); + line-height: 1.8; +} + +.label { + font-size: var(--text-body-sm); + color: var(--text-secondary); +} + +.services { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + margin-top: 12px; +} + +.page input { + width: 100%; + margin: 10px 0; + padding: 10px 12px; + border: 1px solid var(--border-primary); + border-radius: 6px; + background: var(--bg-panel); + color: inherit; + font: inherit; +} diff --git a/src/bundled/link-lab/README.md b/src/bundled/link-lab/README.md new file mode 100644 index 00000000..0242848d --- /dev/null +++ b/src/bundled/link-lab/README.md @@ -0,0 +1,46 @@ +# Link Lab + +First visual experiment for link rendering, activated as a page plugin in the +isolated `/tests/fixtures/link-lab.html` preview. Add `?theme=dark` for dark mode. +Uses the design system's blue 11 text, blue 4 hover fill and Tabler icons. +Links are transparent by default. Padding stays 2px on every side, including +wrapped line fragments, with -2px inline margins so it adds no horizontal gap +to surrounding text. Hovering does not move the surrounding text. +Hover backgrounds have 4px corners. GitHub, Google Drive, Figma, Notion, Slack, +Dropbox, OneDrive, GitLab, YouTube, Loom, Zoom and Teams use their service icons. +Google Docs, Sheets and Slides use document, spreadsheet and presentation icons. +Other websites use a globe. Icons use the same blue foreground and do not fetch +favicons, titles or remote metadata. + +Recognition uses host boundaries and known short-link domains. OneDrive also +recognizes Microsoft's documented +[`-my.sharepoint.com` hosts](https://learn.microsoft.com/en-us/sharepoint/list-onedrive-urls). +Unknown or self-hosted service domains retain the globe icon. + +Sample activation stays in the preview. The separate bundled `buzz.links` plugin +now applies the same component and CSS to HTTPS and supported Buzz links in messages. +Buzz channel, message and thread links have distinct icons and use known channel +names from the current community. Generic labels remain when names are unavailable. +Hover or keyboard focus opens a clickable message preview with author and timestamp on one +row beside the author's avatar, the channel below, and an excerpt clamped to four +lines, ending early before a blank paragraph. The card opens the same destination +with pointer or keyboard activation. Recency follows Buzz desktop's thread-summary ladder: just now, minutes, +hours under 24 hours, days under seven days, then a short date. Hovering the time +shows its full date and time. Hidden channels and +DMs use a lock; other known channels use a hash. This creates a disposable, bounded session thread reader only while open; +it does not mark messages read. Other-community previews do not connect or switch +communities on hover. Mentions with signed recipient IDs and unambiguous loaded +names share the hover styling; known local agent identities use a robot icon. +Known channel names in `#channel` text open through the same host link path. +Both legacy channel/message links and versioned `buzz://open` links are supported. +The host opens them through scoped navigation; message links reveal the selected +message in its verified thread after bounded history loading. Missing targets +report navigation failure. The lab only previews their appearance. +It uses `registerLink`, leaving the existing inline-text contract unchanged. +The lab remains separate from the default catalog. No relay reads, signing, or +persistence are involved in the preview. + +`/tests/fixtures/link-messages.html` exercises real message rows, plugin removal, +failed rendering, labeled Markdown links, clickable previews, and plain fallback. +`tests/browser/buzz-links.spec.mjs` covers scoped navigation and card activation in +Chromium and WebKit. Run the contribution workflow's full batch scan before integration. diff --git a/src/bundled/link-lab/index.tsx b/src/bundled/link-lab/index.tsx new file mode 100644 index 00000000..6ed7b3d5 --- /dev/null +++ b/src/bundled/link-lab/index.tsx @@ -0,0 +1,166 @@ +import { useState } from "react"; +import type { PluginModule } from "../../plugins/api"; +import { targetLink } from "../../features/navigation/targets"; +import { InlineLink, linkKind } from "../links/InlineLink"; +import styles from "./LinkLab.module.css"; + +export const inject = ["pages"]; +export const apply: PluginModule["apply"] = (ctx) => { + ctx.pages.register({ id: "link-lab", title: "Link Lab", component: LinkLab }); +}; + +const buzz = targetLink({ version: 1, kind: "home" }); +const legacyChannel = "buzz://channel/c89a3185-29c5-40db-8284-054536d98b09"; +const legacyMessage = + "buzz://message?channel=c89a3185-29c5-40db-8284-054536d98b09&id=9a77911a6e94147b1ce2cdb3c4e87046c67a29f29f3dd25626134621a5f6924b"; +const thread = targetLink({ + version: 1, + kind: "conversation", + scope: { + viewer: "1".repeat(64), + communityOrigin: "wss://buzz.block.builderlab.xyz", + }, + channelId: "general", + messageId: "2".repeat(64), + threadRootId: "2".repeat(64), +}); + +const serviceSamples = [ + ["Figma", "https://www.figma.com/design/example"], + ["Notion", "https://www.notion.so/example"], + ["Slack", "https://example.slack.com/archives/example"], + ["Dropbox", "https://www.dropbox.com/scl/fo/example"], + ["OneDrive", "https://1drv.ms/f/example"], + ["GitLab", "https://gitlab.com/example/project"], + ["YouTube", "https://youtu.be/example"], + ["Loom", "https://www.loom.com/share/example"], + ["Zoom", "https://us02web.zoom.us/j/example"], + ["Teams", "https://teams.microsoft.com/l/meetup-join/example"], +] as const; + +function LinkLab() { + const [url, setUrl] = useState("https://github.com/block/buzz/pull/1234"); + const [selected, setSelected] = useState( + "Select a sample to try its focus and hover states.", + ); + const preview = (href: string, label?: string) => ( + { + event.preventDefault(); + setSelected( + `Selected: ${label ?? href}. This preview stays on the page.`, + ); + }} + > + {label ?? href} + + ); + return ( +
+

Link Lab

+

+ Blue text, a soft background on hover, and a little context from the + icon. +

+
+
+ Web +

+ The details are on {preview("https://example.com", "example.com")}{" "} + if you want to take a look. +

+
+
+ GitHub +

+ Ready for a look at{" "} + {preview( + "https://github.com/block/buzz/pull/1234", + "block/buzz #1234", + )} + ? The spacing is updated. +

+
+
+ Google Drive +

+ Files live in{" "} + {preview( + "https://drive.google.com/drive/folders/example", + "the shared folder", + )} + , including{" "} + {preview( + "https://docs.google.com/document/d/example/edit", + "the project notes", + )} + ,{" "} + {preview( + "https://docs.google.com/spreadsheets/d/example/edit", + "the tracker", + )} + , and{" "} + {preview( + "https://docs.google.com/presentation/d/example/edit", + "the slide deck", + )} + . +

+
+
+ More services +
+ {serviceSamples.map(([label, href]) => ( + {preview(href, label)} + ))} +
+
+
+ + Buzz channels, messages and threads + +

+ Head back to {preview(buzz, "Buzz")} or pick up{" "} + {preview(thread, "the design discussion")}. +

+

+ Older shared links work too: {preview(legacyChannel)},{" "} + {preview(legacyMessage)}, and{" "} + {preview(`${legacyMessage}&thread=${"2".repeat(64)}`)}. +

+
+
+ Full URLs and wrapping +

+ Here's the reference:{" "} + {preview( + "https://github.com/block/buzz/issues/1234?view=conversation&filter=design-feedback", + )} + . Let me know what you think. +

+
+
+ + setUrl(event.target.value)} + spellCheck={false} + /> +

Take a look at {preview(url)}.

+ {!linkKind(url) && ( +

+ Enter an HTTP, HTTPS, or supported Buzz link. +

+ )} +
+
+

+ {selected} +

+
+ ); +} diff --git a/src/bundled/link-lab/manifest.json b/src/bundled/link-lab/manifest.json new file mode 100644 index 00000000..1257c220 --- /dev/null +++ b/src/bundled/link-lab/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.link-lab", "name": "Link Lab", "apiVersion": 1 } diff --git a/src/bundled/links/InlineLink.test.tsx b/src/bundled/links/InlineLink.test.tsx new file mode 100644 index 00000000..53dae0c8 --- /dev/null +++ b/src/bundled/links/InlineLink.test.tsx @@ -0,0 +1,124 @@ +import { expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { targetLink } from "../../features/navigation/targets"; +import { InlineLink, linkKind } from "./InlineLink"; + +it("identifies exact GitHub hosts, ordinary websites, and valid Buzz locators", () => { + expect(linkKind("https://github.com/block/buzz")).toBe("github"); + expect(linkKind("https://github.com.example.com/block/buzz")).toBe("web"); + expect(linkKind("https://example.com/github.com")).toBe("web"); + expect(linkKind(targetLink({ version: 1, kind: "home" }))).toBe("buzz"); + expect( + linkKind( + targetLink({ + version: 1, + kind: "conversation", + scope: { + viewer: "1".repeat(64), + communityOrigin: "https://example.com", + }, + channelId: "general", + messageId: "2".repeat(64), + threadRootId: "2".repeat(64), + }), + ), + ).toBe("thread"); +}); + +it.each([ + ["buzz://channel/general", "channel", "Channel"], + [`buzz://message?channel=general&id=${"a".repeat(64)}`, "message", "Message"], + [ + `buzz://message?channel=general&id=${"a".repeat(64)}&thread=${"b".repeat(64)}`, + "thread", + "Thread", + ], +])("renders semantic Buzz labels for %s", (href, kind, label) => { + const html = renderToStaticMarkup(); + expect(html).toContain(`data-link-kind="${kind}"`); + expect(html.replace(/<[^>]+>/g, "")).toBe(label); + expect(html).toContain('href="buzz://'); +}); + +it.each([ + "javascript:alert(1)", + "data:text/html,hello", + "buzz://unknown", + "not a URL", +])("leaves unsupported destinations as plain text: %s", (href) => { + expect( + renderToStaticMarkup(Reference), + ).toBe("Reference"); +}); + +it("preserves the destination and readable full label", () => { + const href = "https://github.com/block/buzz/issues/1234"; + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain(`href="${href}"`); + expect(markup).toContain('data-kind="github"'); + expect(markup).toContain('aria-hidden="true"'); + expect(markup.replace(/<[^>]+>/g, "")).toBe(href); +}); + +it.each([ + ["https://drive.google.com/drive/folders/example", "drive"], + ["https://docs.google.com/document/d/example/edit", "document"], + ["https://docs.google.com/spreadsheets/d/example/edit", "spreadsheet"], + ["https://docs.google.com/presentation/d/example/edit", "presentation"], + ["https://docs.google.com/forms/d/example/edit", "drive"], +])("recognizes Google Drive file types: %s", (url, kind) => { + expect(linkKind(url)).toBe(kind); +}); + +it.each([ + ["https://www.figma.com/design/example", "figma"], + ["https://www.notion.so/example", "notion"], + ["https://team.notion.site/example", "notion"], + ["https://workspace.slack.com/archives/example", "slack"], + ["https://app.slack.com/client/example", "slack"], + ["https://www.dropbox.com/scl/fi/example", "dropbox"], + ["https://db.tt/example", "dropbox"], + ["https://onedrive.live.com/?id=example", "onedrive"], + ["https://1drv.ms/w/example", "onedrive"], + ["https://example-my.sharepoint.com/personal/example", "onedrive"], + ["https://example.sharepoint.com/sites/example", "web"], + ["https://example-my.sharepoint.com.evil.test/personal/example", "web"], + ["https://gitlab.com/example/project", "gitlab"], + ["https://www.youtube.com/watch?v=example", "youtube"], + ["https://youtu.be/example", "youtube"], + ["https://www.loom.com/share/example", "loom"], + ["https://us02web.zoom.us/j/example", "zoom"], + ["https://zoom.com/j/example", "zoom"], + ["https://teams.microsoft.com/l/meetup-join/example", "teams"], + ["https://teams.live.com/meet/example", "teams"], + ["https://teams.cloud.microsoft/l/meetup-join/example", "teams"], +])("renders the service icon for %s", (href, kind) => { + expect(linkKind(href)).toBe(kind); + const markup = renderToStaticMarkup(); + expect(markup).toContain(`data-link-kind="${kind}"`); + expect(markup.replace(/<[^>]+>/g, "")).toBe(href); +}); + +it.each([ + "figma.com", + "notion.so", + "slack.com", + "dropbox.com", + "onedrive.live.com", + "gitlab.com", + "youtube.com", + "loom.com", + "zoom.us", + "teams.microsoft.com", +])("does not brand lookalike destinations for %s", (host) => { + expect(linkKind(`https://${host}.example.com/file`)).toBe("web"); + expect(linkKind(`https://fake${host}/file`)).toBe("web"); + expect(linkKind(`https://example.com/${host}`)).toBe("web"); + expect(linkKind(`https://${host}@example.com/file`)).toBe("web"); +}); +it("does not mistake lookalike hosts or URL paths for Google Drive", () => { + expect(linkKind("https://drive.google.com.example.com/file")).toBe("web"); + expect(linkKind("https://example.com/drive.google.com")).toBe("web"); +}); diff --git a/src/bundled/links/InlineLink.tsx b/src/bundled/links/InlineLink.tsx new file mode 100644 index 00000000..49d3d3fc --- /dev/null +++ b/src/bundled/links/InlineLink.tsx @@ -0,0 +1,162 @@ +import { + IconBrandGithub, + IconBrandGoogleDrive, + IconBrandFigma, + IconBrandNotion, + IconBrandSlack, + IconBrandDropbox, + IconBrandOnedrive, + IconBrandGitlab, + IconBrandYoutube, + IconBrandLoom, + IconBrandZoom, + IconBrandTeams, + IconFileText, + IconFileSpreadsheet, + IconPresentation, + IconWorld, + IconMessageCircle, + IconMessages, + IconHash, +} from "@tabler/icons-react"; +import { useContext, type ComponentProps } from "react"; +import { + LinkLabelContext, + LinkContentContext, +} from "../../features/conversation/LinkLabelContext"; +import { buzzLinkKind } from "../../features/navigation/buzz-links"; +import styles from "../../shared/InlineReference.module.css"; + +// Match actual host boundaries, never a service name found in a path or query. +const serviceHosts = { + github: ["github.com"], + figma: ["figma.com"], + notion: ["notion.so", "notion.site"], + slack: ["slack.com"], + dropbox: ["dropbox.com", "dropboxusercontent.com", "db.tt"], + onedrive: ["onedrive.live.com", "1drv.ms"], + gitlab: ["gitlab.com"], + youtube: ["youtube.com", "youtu.be"], + loom: ["loom.com"], + zoom: ["zoom.us", "zoom.com"], + teams: ["teams.microsoft.com", "teams.live.com", "teams.cloud.microsoft"], +} as const; + +export function linkKind(href: string) { + try { + const url = new URL(href); + if (url.protocol === "buzz:") { + return buzzLinkKind(href); + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + // Microsoft's OneDrive for Business host shape; ordinary SharePoint stays generic. + if (/^[a-z0-9-]+-my\.sharepoint\.com$/.test(url.hostname)) + return "onedrive"; + if (url.hostname === "drive.google.com") return "drive"; + if (url.hostname === "docs.google.com") { + const product = url.pathname.split("/")[1]; + if (product === "document") return "document"; + if (product === "spreadsheets") return "spreadsheet"; + if (product === "presentation") return "presentation"; + return "drive"; + } + for (const kind of Object.keys( + serviceHosts, + ) as (keyof typeof serviceHosts)[]) { + if ( + serviceHosts[kind].some( + (host) => url.hostname === host || url.hostname.endsWith(`.${host}`), + ) + ) + return kind; + } + return "web"; + } catch { + return null; + } +} + +const icons = { + web: IconWorld, + github: IconBrandGithub, + drive: IconBrandGoogleDrive, + figma: IconBrandFigma, + notion: IconBrandNotion, + slack: IconBrandSlack, + dropbox: IconBrandDropbox, + onedrive: IconBrandOnedrive, + gitlab: IconBrandGitlab, + youtube: IconBrandYoutube, + loom: IconBrandLoom, + zoom: IconBrandZoom, + teams: IconBrandTeams, + document: IconFileText, + spreadsheet: IconFileSpreadsheet, + presentation: IconPresentation, + buzz: IconMessageCircle, + channel: IconHash, + message: IconMessageCircle, + thread: IconMessages, +}; + +/** Anchor wrapper for the isolated lab; messages use LinkLabel in a host-owned anchor. */ +export function InlineLink({ + href, + children, + ...props +}: Omit, "className" | "children"> & { + href: string; + children?: string; +}) { + const kind = linkKind(href); + if (!kind) return <>{children ?? href}; + return ( + + + + ); +} + +export function LinkLabel({ + href, + label = href, +}: { + href: string; + label?: string; +}) { + const contextualLabel = useContext(LinkLabelContext); + const content = useContext(LinkContentContext); + const kind = linkKind(href); + if (!kind) return <>{label}; + if (label === href && contextualLabel) label = contextualLabel; + if (label === href) { + if (kind === "channel") label = "Channel"; + else if (kind === "message") label = "Message"; + else if (kind === "thread") label = "Thread"; + else if (kind === "buzz") label = "Buzz"; + } + // The channel icon already supplies the visual hash. + if (kind === "channel" && label.startsWith("#")) label = label.slice(1); + const Icon = icons[kind]; + if (content !== undefined) + return ( + + + ); + // Keep the icon with the start of its label, while allowing long URLs to wrap. + const lead = (label.match(/^(?:https?:\/\/)?[^/\s]+/)?.[0] ?? label).slice( + 0, + 24, + ); + return ( + + + + {label.slice(lead.length)} + + ); +} diff --git a/src/bundled/links/index.tsx b/src/bundled/links/index.tsx new file mode 100644 index 00000000..d728093e --- /dev/null +++ b/src/bundled/links/index.tsx @@ -0,0 +1,14 @@ +import type { PluginModule } from "../../plugins/api"; +import { LinkLabel, linkKind } from "./InlineLink"; +import styles from "../../shared/InlineReference.module.css"; + +export const inject = ["conversation"]; +export const apply: PluginModule["apply"] = (ctx) => { + ctx.conversation.registerLink({ + id: "links", + title: "Links", + matches: (url) => linkKind(url) !== null, + className: styles.link, + component: ({ url }) => , + }); +}; diff --git a/src/bundled/links/manifest.json b/src/bundled/links/manifest.json new file mode 100644 index 00000000..139231a0 --- /dev/null +++ b/src/bundled/links/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.links", "name": "Links", "apiVersion": 1 } diff --git a/src/features/conversation/BuzzLinkPreview.tsx b/src/features/conversation/BuzzLinkPreview.tsx new file mode 100644 index 00000000..9e8736bc --- /dev/null +++ b/src/features/conversation/BuzzLinkPreview.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; +import type { ThreadView } from "../relay/threads"; +import { IconHash, IconLock } from "@tabler/icons-react"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { relativeTimestamp } from "../../shared/relative-timestamp"; +import styles from "./LinkPreview.module.css"; +import { messagePreviewText } from "./message-preview-text"; + +/** Mounted only while a preview is open. The session owns access, edits and deletion. */ +export function BuzzLinkPreview({ + session, + channelId, + messageId, +}: { + session: RelaySession; + channelId: string; + messageId: string; +}) { + const [view, setView] = useState(); + const [error, setError] = useState(false); + useEffect(() => { + try { + const owned = session.thread(channelId, messageId); + setView(owned); + void owned.refresh(); + return () => owned.dispose(); + } catch { + setError(true); + } + }, [session, channelId, messageId]); + if (error) return Message preview unavailable.; + return view ? ( + + ) : ( + Loading message… + ); +} + +function PreviewContent({ + view, + session, + messageId, +}: { + view: ThreadView; + session: RelaySession; + messageId: string; +}) { + const snapshot = useSyncExternalStore( + view.subscribe, + view.snapshot, + view.snapshot, + ); + const message = [snapshot.root, ...snapshot.replies].find( + (row) => row?.id === messageId, + ); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + session.profiles.snapshot, + ); + const channels = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + session.channels.list, + ); + useEffect(() => { + if (snapshot.status === "ready" && !message && snapshot.canLoadMore) + void view.loadMore(); + }, [snapshot, message, view]); + const authorId = message?.authorId; + useEffect(() => { + if (authorId) + void session.profiles.ensure([authorId], "background").catch(() => {}); + }, [session, authorId]); + if (snapshot.status === "error") + return Message preview unavailable.; + if (!message || !snapshot.root) + return ( + + {snapshot.status === "ready" && !snapshot.canLoadMore + ? "Message preview unavailable." + : "Loading message…"} + + ); + const profile = profiles.get(message.authorId); + const name = profile?.name ?? message.authorId.slice(0, 10); + const date = new Date(message.createdAt * 1000); + const channel = channels.channels.find( + (item) => item.id === message.channelId, + ); + const channelName = + channel?.channelType === "dm" && channel.participants + ? channel.participants + .map((id) => profiles.get(id)?.name ?? id.slice(0, 10)) + .join(", ") || "Notes to self" + : (channel?.name ?? "Channel unavailable"); + const ChannelIcon = + channel?.hidden || channel?.channelType === "dm" ? IconLock : IconHash; + return ( + <> + + + + + {name} + + + + {channel && + + + + {messagePreviewText(message.content) || + (message.attachments.length ? "Attachment" : "Empty message")} + + + ); +} diff --git a/src/features/conversation/LinkLabelContext.ts b/src/features/conversation/LinkLabelContext.ts new file mode 100644 index 00000000..df1fcc78 --- /dev/null +++ b/src/features/conversation/LinkLabelContext.ts @@ -0,0 +1,7 @@ +import { createContext, type ReactNode } from "react"; + +/** Optional host-resolved display label; presentation never resolves a destination. */ +export const LinkLabelContext = createContext(undefined); + +/** Authored Markdown formatting stays inside the host anchor. */ +export const LinkContentContext = createContext(undefined); diff --git a/src/features/conversation/LinkPreview.module.css b/src/features/conversation/LinkPreview.module.css new file mode 100644 index 00000000..5cb82069 --- /dev/null +++ b/src/features/conversation/LinkPreview.module.css @@ -0,0 +1,71 @@ +.popup { + /* Content layout; the shared PreviewCard owns surface, border and elevation. */ + --radius-control: var(--corner-control); + width: min(340px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + max-height: min(320px, var(--available-height)); + overflow: auto; + padding: var(--space-3); + gap: var(--space-2); + color: var(--text-primary); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + letter-spacing: var(--text-body--letter-spacing); + font-weight: var(--text-body--font-weight); +} +.byline { + display: flex; + align-items: center; + gap: var(--space-2); +} +.metadata { + flex: 1; + min-width: 0; +} +.author { + display: flex; + align-items: baseline; + gap: var(--space-1); +} +.byline strong { + font-weight: var(--type-weight-semibold); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.byline time { + flex-shrink: 0; + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); + font-weight: var(--text-body-sm--font-weight); + color: var(--text-secondary); +} +.channel { + display: flex; + align-items: center; + gap: 0; + margin-top: 2px; + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); + font-weight: var(--text-body-sm--font-weight); + color: var(--text-secondary); +} +.channel svg { + flex-shrink: 0; +} +.channel > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.message { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/src/features/conversation/MessageLink.test.tsx b/src/features/conversation/MessageLink.test.tsx new file mode 100644 index 00000000..9836b5a5 --- /dev/null +++ b/src/features/conversation/MessageLink.test.tsx @@ -0,0 +1,44 @@ +import { expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Contribution } from "../../plugins/contributions"; +import type { LinkRenderer } from "./contracts"; +import { MessageLink, resolveLink } from "./MessageLink"; + +const entry: Contribution = { + id: "link", + title: "Link", + key: "test/link", + pluginId: "test", + revision: "one", + matches: () => true, + className: "link-style", + component: () => Link face, +}; +const url = "https://example.com/path?query=yes"; +it("keeps anchor destination and host semantics with and without presentation", () => { + const registry = { snapshot: () => [entry], subscribe: () => () => {} }; + const html = renderToStaticMarkup( + true} />, + ); + expect(html).toContain(`href="${url}"`); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + expect(html).toContain('class="link-style"'); + expect(html).toContain("Link face"); + const fallback = renderToStaticMarkup( + true} />, + ); + expect(fallback).toContain(`>${url}`); + expect(fallback).not.toContain("data-link-renderer"); +}); +it("skips throwing and unmatched renderers, with first matching presentation winning", () => { + const broken = { + ...entry, + matches() { + throw new Error("matcher failed"); + }, + }; + const miss = { ...entry, matches: () => false }; + expect(resolveLink(url, [broken, miss, entry, { ...entry }])).toBe(entry); + expect(resolveLink(url, [broken, miss])).toBeUndefined(); +}); diff --git a/src/features/conversation/MessageLink.tsx b/src/features/conversation/MessageLink.tsx new file mode 100644 index 00000000..a38f2eb7 --- /dev/null +++ b/src/features/conversation/MessageLink.tsx @@ -0,0 +1,166 @@ +import { + useState, + useSyncExternalStore, + type ReactNode, + type MouseEvent, +} from "react"; +import type { Contribution } from "../../plugins/contributions"; +import type { ContributionReader, LinkRenderer } from "./contracts"; +import { ContributionBoundary, contributionKey } from "./ContributionBoundary"; +import { PreviewCard } from "../../shared/design-system/ui/PreviewCard"; +import type { RelaySession } from "../relay/session"; +import { parseBuzzLink } from "../navigation/buzz-links"; +import { LinkLabelContext, LinkContentContext } from "./LinkLabelContext"; +import { BuzzLinkPreview } from "./BuzzLinkPreview"; +import { messageViewKey } from "../messages/view-key"; +import styles from "./LinkPreview.module.css"; + +const empty: readonly Contribution[] = []; +const snapshot = () => empty; +const subscribe = () => () => {}; + +/** First active match wins, like panels. A broken matcher leaves other candidates eligible. */ +export function resolveLink( + url: string, + renderers: readonly Contribution[], +) { + for (const renderer of renderers) { + try { + if (renderer.matches(url)) return renderer; + } catch { + // An optional renderer must not prevent opening a link. + } + } + return undefined; +} + +export function MessageLink({ + url, + children, + registry, + onOpenLink, + label, + session, + scope, +}: { + url: string; + children?: ReactNode; + registry: ContributionReader | undefined; + onOpenLink(url: string): boolean; + label?: string | undefined; + session?: RelaySession | undefined; + scope?: string | undefined; +}) { + const renderers = useSyncExternalStore( + registry?.subscribe ?? subscribe, + registry?.snapshot ?? snapshot, + registry?.snapshot ?? snapshot, + ); + const renderer = resolveLink(url, renderers); + const [unavailable, setUnavailable] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const internal = url.startsWith("buzz://"); + const parsed = internal ? parseBuzzLink(url) : null; + const destination = + parsed?.format === "legacy" + ? parsed + : parsed?.target.kind === "conversation" && + scope?.slice(0, -65) === parsed.target.scope.communityOrigin + ? parsed.target + : undefined; + const preview = + session && destination?.messageId + ? { channelId: destination.channelId, messageId: destination.messageId } + : undefined; + const navigation = { + target: "_blank", + rel: "noopener noreferrer", + onClick: (event: MouseEvent) => { + if (internal) { + event.preventDefault(); + const opened = onOpenLink(url); + setUnavailable(!opened); + if (opened) setPreviewOpen(false); + return; + } + if ( + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + onOpenLink(url) + ) + event.preventDefault(); + }, + onAuxClick: internal + ? (event: MouseEvent) => { + if (event.button === 1) { + event.preventDefault(); + const opened = onOpenLink(url); + setUnavailable(!opened); + if (opened) setPreviewOpen(false); + } + } + : undefined, + }; + const anchor = (entry?: Contribution) => { + const Content = entry?.component; + const element = ( + + {Content ? : (children ?? label ?? url)} + + ); + return preview && session ? ( + } + open={previewOpen} + onOpenChange={setPreviewOpen} + side="top" + className={styles.popup ?? ""} + aria-label="Message preview" + > + {previewOpen && ( + + )} + + ) : ( + element + ); + }; + const link = renderer ? ( + + {anchor(renderer)} + + ) : ( + anchor() + ); + const result = ( + <> + {link} + {unavailable && ( + This Buzz link couldn’t be opened here. + )} + + ); + return ( + + {result} + + ); +} diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index b6bedd04..d29a0c75 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -42,6 +42,14 @@ export type InlineRenderer = Readonly<{ media(url: string): string | undefined; }>; }>; +/** Link presentation only. The host retains the anchor, destination and activation. */ +export type LinkRenderer = Readonly<{ + id: string; + title: string; + matches(url: string): boolean; + className?: string | undefined; + component: ComponentType<{ url: string }>; +}>; export type ContributionReader = Readonly<{ snapshot(): readonly Contribution[]; subscribe(listener: () => void): () => void; @@ -50,6 +58,7 @@ export type ConversationExtensions = Readonly<{ tools: ContributionReader; inline: ContributionReader; completions?: ContributionReader; + links?: ContributionReader; }>; /** Immutable host-issued evidence, scoped to one live editor observation. */ diff --git a/src/features/conversation/message-preview-text.test.ts b/src/features/conversation/message-preview-text.test.ts new file mode 100644 index 00000000..6ed0cff5 --- /dev/null +++ b/src/features/conversation/message-preview-text.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vitest"; +import { messagePreviewText } from "./message-preview-text"; + +it("ends the excerpt before blank lines, with an ellipsis beside the text", () => { + expect( + messagePreviewText("Watch at 2x if you must lol\n\n\nVideo details"), + ).toBe("Watch at 2x if you must lol…"); + expect(messagePreviewText("First paragraph\r\n \t\r\nMore text")).toBe( + "First paragraph…", + ); +}); + +it("preserves single line breaks and leaves visual wrapping to the four-line clamp", () => { + const text = "First line\nSecond line"; + expect(messagePreviewText(text)).toBe(text); + const longParagraph = "A long message. ".repeat(100).trim(); + expect(messagePreviewText(longParagraph)).toBe(longParagraph); +}); + +it("ignores surrounding blank lines without implying missing content", () => { + expect(messagePreviewText("\n\nShort message\n \n")).toBe("Short message"); + expect(messagePreviewText("\r\n \t\n")).toBe(""); +}); diff --git a/src/features/conversation/message-preview-text.ts b/src/features/conversation/message-preview-text.ts new file mode 100644 index 00000000..859c918c --- /dev/null +++ b/src/features/conversation/message-preview-text.ts @@ -0,0 +1,8 @@ +/** A compact excerpt: stop before an empty paragraph rather than clamping a blank line. */ +export function messagePreviewText(content: string): string { + const text = content.trim(); + const paragraphBreak = /\r?\n[^\S\r\n]*\r?\n/.exec(text); + return paragraphBreak + ? `${text.slice(0, paragraphBreak.index).trimEnd()}…` + : text; +} diff --git a/src/features/conversation/service.test.tsx b/src/features/conversation/service.test.tsx index 84d88f24..1cbc93e4 100644 --- a/src/features/conversation/service.test.tsx +++ b/src/features/conversation/service.test.tsx @@ -9,6 +9,7 @@ import type { InlineRenderer } from "./contracts"; import type { Contribution } from "../../plugins/contributions"; import * as emoji from "../../bundled/emoji"; import * as mentions from "../../bundled/mentions"; +import * as links from "../../bundled/links"; const Component = () => null; const cleanups: (() => Promise)[] = []; @@ -166,6 +167,41 @@ it("bundled Mentions registers only a chooser and removal leaves the host UI ava expect(h.service.ui.Composer).toBe(composer); }); +it("bundled Links registers, withdraws, and restores a fresh renderer", async () => { + const h = harness(links); + h.runtime.reconcile([h.plugin]); + await vi.waitFor(() => expect(h.service.links.snapshot()).toHaveLength(1)); + const first = h.service.links.snapshot()[0]; + expect(first?.matches("https://github.com/block/buzz")).toBe(true); + expect(first?.matches("javascript:alert(1)")).toBe(false); + h.runtime.reconcile([]); + await vi.waitFor(() => expect(h.service.links.snapshot()).toHaveLength(0)); + h.runtime.reconcile([{ ...h.plugin, revision: "two" }]); + await vi.waitFor(() => expect(h.service.links.snapshot()).toHaveLength(1)); + expect(h.service.links.snapshot()[0]).not.toBe(first); + expect(h.service.links.snapshot()[0]?.revision).toBe("two"); +}); + +it("withdraws link presentation when plugin activation fails", async () => { + const h = harness({ + inject: ["conversation"], + apply(ctx) { + ctx.conversation.registerLink({ + id: "link", + title: "Link", + matches: () => true, + component: Component, + }); + throw new Error("failed link plugin"); + }, + }); + h.runtime.reconcile([h.plugin]); + await vi.waitFor(() => + expect(h.runtime.snapshot()[h.plugin.manifest.id]?.status).toBe("failed"), + ); + expect(h.service.links.snapshot()).toHaveLength(0); +}); + it("owns completion registration through disable, replacement and failed activation", async () => { const h = harness({ inject: ["conversation"], diff --git a/src/features/conversation/service.tsx b/src/features/conversation/service.tsx index 48a05f77..46c6f9ce 100644 --- a/src/features/conversation/service.tsx +++ b/src/features/conversation/service.tsx @@ -11,6 +11,7 @@ import type { ComposerTool, ComposerCompletion, InlineRenderer, + LinkRenderer, ContributionReader, } from "./contracts"; @@ -21,6 +22,8 @@ export type Conversation = { registerCompletion(provider: ComposerCompletion): void; inline: ContributionReader; registerInline(renderer: InlineRenderer): void; + links: ContributionReader; + registerLink(renderer: LinkRenderer): void; ui: { Composer: (props: Omit) => ReactNode; Message: (props: Omit) => ReactNode; @@ -31,7 +34,9 @@ declare module "@deepseek-ai/cordis" { conversation: Conversation; } } -function validate(value: ComposerTool | InlineRenderer | ComposerCompletion) { +function validate( + value: ComposerTool | InlineRenderer | ComposerCompletion | LinkRenderer, +) { if ( !value || !/^[a-z0-9][a-z0-9._-]*$/.test(value.id) || @@ -50,6 +55,8 @@ export class ConversationService extends Service implements Conversation { readonly inline; private readonly toolEntries; private readonly inlineEntries; + readonly links; + private readonly linkEntries; constructor(ctx: Context) { super(ctx, "conversation"); const tools = createContributions(ctx); @@ -64,6 +71,9 @@ export class ConversationService extends Service implements Conversation { this.inlineEntries = inline; this.tools = { snapshot: tools.snapshot, subscribe: tools.subscribe }; this.inline = { snapshot: inline.snapshot, subscribe: inline.subscribe }; + const links = createContributions(ctx); + this.linkEntries = links; + this.links = { snapshot: links.snapshot, subscribe: links.subscribe }; } registerTool(value: ComposerTool) { validate(value); @@ -81,6 +91,14 @@ export class ConversationService extends Service implements Conversation { throw new Error("An inline renderer needs a matcher"); this.inlineEntries.register(this.ctx, value); } + registerLink(value: LinkRenderer) { + validate(value); + if (typeof value.matches !== "function") + throw new Error("A link renderer needs a matcher"); + if (value.className !== undefined && typeof value.className !== "string") + throw new Error("A link renderer class must be a string"); + this.linkEntries.register(this.ctx, value); + } readonly ui = { Composer: (props: Omit) => ( diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 1312d2f9..764e5559 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -433,6 +433,8 @@ function Timeline({ /> ) : ( { it("binds exact signed names longest-first through surrounding emphasis", () => { const html = render("**@Mic Smith**, _@Mic_! @Other @Missing @Microscopic"); - expect(html).toContain( - ``, - ); + expect(html).toContain("Mic Smith"); // The raw helper conservatively treats trailing underscore as a name suffix. expect(html.match(/`, - ); + expect(html).toContain(`aria-label="View ${name} profile"`); + expect(html).toContain(`${name}`); expect(html).not.toContain(""); expect(html).not.toContain(" { ); }); }); + +it.each([false, true])( + "retains formatting inside labeled links (plugin enabled: %s)", + (enabled) => { + const links = { + snapshot: () => + enabled + ? [ + { + id: "link", + title: "Links", + key: "links/link", + pluginId: "links", + revision: "one", + matches: () => true, + component: ({ url }: { url: string }) => ( + + ), + }, + ] + : [], + subscribe: () => () => {}, + }; + const html = render( + "[**Important** or `code`](https://github.com/block/buzz-app)", + { extensions: { ...extensions, links } }, + ); + expect(html).toContain("Important"); + expect(html).toContain("code"); + expect(html).toContain('href="https://github.com/block/buzz-app"'); + expect(html.includes('data-link-kind="github"')).toBe(enabled); + }, +); + +it("keeps resolved channel labels for Buzz autolinks", () => { + const entry = { + id: "link", + title: "Links", + key: "links/link", + pluginId: "links", + revision: "one", + matches: () => true, + component: ({ url }: { url: string }) => , + }; + const href = `buzz://message?channel=design&id=${"a".repeat(64)}`; + const html = render(`<${href}> `, { + directory: { + profiles: new Map(), + agents: [], + channels: [ + { + id: "design", + name: "design", + channelType: "forum", + }, + ], + }, + extensions: { + ...extensions, + links: { snapshot: () => [entry], subscribe: () => () => {} }, + }, + }); + expect(html).toContain('data-link-kind="message"'); + expect(html).toContain('data-link-kind="channel"'); + const text = html.replace(/<[^>]*>/g, ""); + expect(text).toContain("design"); + expect(text).not.toContain("buzz://"); +}); diff --git a/src/features/messages/MessageMarkdown.tsx b/src/features/messages/MessageMarkdown.tsx index b6284e5d..6692770f 100644 --- a/src/features/messages/MessageMarkdown.tsx +++ b/src/features/messages/MessageMarkdown.tsx @@ -1,4 +1,16 @@ -import type { AnchorHTMLAttributes, MouseEvent } from "react"; +import { Children, isValidElement, type ReactNode } from "react"; +import type { RelaySession } from "../relay/session"; +import { MessageLink } from "../conversation/MessageLink"; +import { parseBuzzLink } from "../navigation/buzz-links"; +import { messageLinkParts, normalizeWrappedLinks } from "./message-link-parts"; +import { + ReferenceText, + channelLinkLabel, + emptyReferenceDirectory, +} from "./ReferenceText"; +import { IconAt, IconRobot } from "@tabler/icons-react"; +import { profileKey } from "../profiles/target"; +import referenceStyles from "../../shared/InlineReference.module.css"; import Markdown, { type Components, type UrlTransform } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; @@ -201,42 +213,22 @@ function remarkInlineContent(protectedContent: ProtectedContent) { }; } -const transformUrl: UrlTransform = (value) => safeMessageUrl(value); - -function MessageLink({ - href, - onOpenLink, - children, - ...props -}: AnchorHTMLAttributes & { - onOpenLink(url: string): boolean; -}) { - const url = href ? safeMessageUrl(href) : undefined; - if (!url) return {children}; - return ( - ) => { - if ( - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - onOpenLink(url) - ) - event.preventDefault(); - }} - > - {children} - - ); -} +const transformUrl: UrlTransform = (value) => + parseBuzzLink(value) ? value : safeMessageUrl(value); +const labelText = (children: ReactNode): string => + Children.toArray(children) + .map((child) => + isValidElement<{ children?: ReactNode }>(child) + ? labelText(child.props.children) + : String(child), + ) + .join(""); export function MessageMarkdown({ row, + directory = emptyReferenceDirectory, + session, + scope, extensions, media, onOpenLink, @@ -245,6 +237,9 @@ export function MessageMarkdown({ largeEmoji = false, }: { row: ChannelMessage; + directory?: typeof emptyReferenceDirectory; + session?: RelaySession | undefined; + scope?: string | undefined; extensions?: ConversationExtensions | undefined; media(url: string): string | undefined; onOpenLink(url: string): boolean; @@ -254,13 +249,75 @@ export function MessageMarkdown({ }) { if (row.content.length > MAX_MARKDOWN_LENGTH) return
{row.content}
; - const scan = scanMarkdown(row.content); + let scan = scanMarkdown(row.content); if (scan.tooDeep) return
{row.content}
; + const literalRanges: { start: number; end: number }[] = []; + const collectLiterals = (node: MarkdownNode) => { + if (literalContext(node.type) && node.type !== "link") { + const start = node.position?.start.offset, + end = node.position?.end.offset; + if (start !== undefined && end !== undefined) + literalRanges.push({ start, end }); + } else for (const child of node.children ?? []) collectLiterals(child); + }; + collectLiterals(scan.tree); + const normalized = normalizeWrappedLinks(row.content, (start, end) => + literalRanges.some((range) => start < range.end && end > range.start), + ); + if (normalized !== row.content) { + row = { ...row, content: normalized }; + scan = scanMarkdown(normalized); + } + const renderLink = (url: string, label?: string, children?: ReactNode) => ( + + {children} + + ); + const renderInline = (text: string) => + extensions ? ( + + ) : ( + text + ); + const renderText = (text: string) => { + let offset = 0; + return messageLinkParts(text).map((part) => { + const key = `${offset}:${part.text}`; + offset += part.text.length; + return part.url ? ( + {renderLink(part.url, part.label)} + ) : ( + + ); + }); + }; + const protectedContent = protectInlineContent( row, - participantProfiles, + participantProfiles ?? directory.profiles, scan.tree, ); const components: Components = { @@ -271,9 +328,16 @@ export function MessageMarkdown({ data-single-emoji={largeEmoji || undefined} /> ), - a: ({ node: _node, ...props }) => ( - - ), + a: ({ href, children }) => + href ? ( + renderLink( + href, + labelText(children) === href ? undefined : labelText(children), + labelText(children) === href ? undefined : children, + ) + ) : ( + {children} + ), img: ({ node: _node, alt }) => alt ? {alt} : null, span: ({ node: _node, children, ...props }) => { @@ -287,30 +351,28 @@ export function MessageMarkdown({ typeof target === "string" && canOpenLink?.(target) ) { + const agent = directory.agents.some( + (agent) => agent.pubkey === profileKey(target), + ); + const Icon = agent ? IconRobot : IconAt; return ( ); } return typeof text === "string" ? ( - extensions ? ( - - ) : ( - text - ) + renderText(text) ) : ( {children} ); diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 19808995..c89b0717 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -5,6 +5,7 @@ import { keypair, message, signed } from "../relay/testing"; import { MessageRow } from "./MessageRow"; import type { ChannelMessage } from "../relay/contracts"; import type { UnreadCapability, UnreadSnapshot } from "../relay/unread"; +import { LinkLabel } from "../../bundled/links/InlineLink"; const row: ChannelMessage = { id: "root", @@ -18,6 +19,60 @@ const row: ChannelMessage = { reactions: [], replyCount: 23, }; +it.each(["bare", "angle", "markdown", "escaped"] as const)( + "renders link contributions inside message prose, preserving punctuation and plain-link fallback (%s)", + (format) => { + const url = "https://github.com/block/buzz/issues/1234"; + const label = + format === "markdown" || format === "escaped" ? "Repository" : url; + const content = { + bare: url, + angle: `<${url}>`, + markdown: `[Repository](${url})`, + escaped: `[Repository]\\([${url}](${url}))`, + }[format]; + const entry = { + id: "link", + title: "Link", + key: "buzz.links/link", + pluginId: "buzz.links", + revision: "one", + matches: () => true, + component: ({ url }: { url: string }) => , + }; + const render = (enabled: boolean) => + renderToStaticMarkup( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + extensions={{ + tools: { snapshot: () => [], subscribe: () => () => {} }, + inline: { snapshot: () => [], subscribe: () => () => {} }, + links: { + snapshot: () => (enabled ? [entry] : []), + subscribe: () => () => {}, + }, + }} + />, + ); + const enabled = render(true); + expect(enabled).toContain(`href="${url}"`); + expect(enabled).toContain('data-link-kind="github"'); + expect(enabled.replace(/<[^>]+>/g, "")).toContain(`Before ${label}. After`); + expect(enabled).not.toContain("<"); + expect(enabled).not.toContain(">"); + expect(render(false)).not.toContain("data-link-kind"); + expect(render(false)).toContain(`>${label}`); + }, +); function render( patch: Partial, replies = 23, diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 5f7eb38d..05caf643 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -1,10 +1,12 @@ import { memo, useCallback, useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; import type { UnreadCapability } from "../relay/unread"; import { profileTarget } from "../profiles/target"; import { InlineText } from "../conversation/InlineText"; import type { ConversationExtensions } from "../conversation/contracts"; import type { ChannelMessage, Profile } from "../relay/contracts"; import { DeliveryNotice } from "./DeliveryNotice"; +import { useReferenceDirectory } from "./ReferenceText"; import { MessageMarkdown } from "./MessageMarkdown"; import { safeMessageUrl } from "../relay/message-content"; import styles from "./Messages.module.css"; @@ -12,6 +14,8 @@ import { usesLargeEmojiPresentation } from "./emoji-size"; export type MessageRowProps = { row: ChannelMessage; + session?: RelaySession | undefined; + scope?: string | undefined; unread?: UnreadCapability | undefined; extensions?: ConversationExtensions | undefined; profile: Profile | undefined; @@ -26,6 +30,8 @@ export type MessageRowProps = { export const MessageRow = memo(function MessageRow({ row, + session, + scope, unread, extensions, profile, @@ -37,6 +43,7 @@ export const MessageRow = memo(function MessageRow({ onOpenThread, participantProfiles, }: MessageRowProps) { + const directory = useReferenceDirectory(session); const threadUnread = useThreadUnread( row.replyCount > 0 && onOpenThread ? unread : undefined, row.channelId, @@ -100,6 +107,9 @@ export const MessageRow = memo(function MessageRow({ = new Map(); +const emptyChannels: readonly ChannelSummary[] = []; +const emptyAgents: AgentLibrary["identities"] = []; +export const emptyReferenceDirectory = { + profiles: emptyProfiles, + channels: emptyChannels, + agents: emptyAgents, +}; +const noop = () => () => {}; +const profilesSnapshot = () => emptyProfiles; +const channelsSnapshot = () => undefined; +const agentsSnapshot = () => undefined; + +export function useReferenceDirectory(session?: RelaySession) { + const profiles = useSyncExternalStore( + session?.profiles?.subscribe ?? noop, + session?.profiles?.snapshot ?? profilesSnapshot, + profilesSnapshot, + ); + const channels = useSyncExternalStore( + session?.channels?.subscribeList ?? noop, + session?.channels?.list ?? channelsSnapshot, + channelsSnapshot, + ); + const agents = useSyncExternalStore( + session?.agentLibrary?.subscribe ?? noop, + session?.agentLibrary?.snapshot ?? agentsSnapshot, + agentsSnapshot, + ); + useEffect(() => { + if (agents?.status === "idle") void session?.agentLibrary.refresh(); + }, [session, agents?.status]); + return { + profiles, + channels: channels?.channels ?? emptyChannels, + agents: agents?.identities ?? emptyAgents, + }; +} + +export function channelLinkLabel( + url: string, + scope: string | undefined, + channels: readonly ChannelSummary[], +) { + const parsed = parseBuzzLink(url); + const target = + parsed?.format === "legacy" + ? parsed + : parsed?.target.kind === "conversation" && + parsed.target.scope.communityOrigin === scope?.slice(0, -65) + ? parsed.target + : undefined; + const channel = + target && channels.find((item) => item.id === target.channelId); + return channel + ? `${channel.channelType === "dm" || target.messageId ? "" : "#"}${channel.name}` + : undefined; +} + +export function ReferenceText({ + text, + mentions, + directory, + renderText, + onOpenLink, + extensions, + session, + scope, +}: { + text: string; + mentions: readonly string[]; + directory: ReturnType; + renderText(text: string): ReactNode; + onOpenLink(url: string): boolean; + extensions?: ConversationExtensions | undefined; + session?: RelaySession | undefined; + scope?: string | undefined; +}) { + const references = messageReferences( + text, + mentions, + directory.profiles, + directory.channels, + directory.agents, + ); + if (!references.length) return renderText(text); + const parts: ReactNode[] = []; + let offset = 0; + for (const reference of references) { + parts.push( + + {renderText(text.slice(offset, reference.start))} + , + ); + const Icon = reference.kind === "agent" ? IconRobot : IconAt; + parts.push( + reference.kind === "channel" ? ( + + ) : ( + + + ), + ); + offset = reference.end; + } + parts.push( + {renderText(text.slice(offset))}, + ); + return <>{parts}; +} diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index 8ef9d60b..e956a468 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -7,6 +7,7 @@ import { MessageComposer } from "./MessageComposer"; import type { RelaySession } from "../relay/session"; import type { ThreadSnapshot, ThreadView } from "../relay/threads"; import type { ChannelMessage } from "../relay/contracts"; +import type { PageNavigation } from "../navigation/service"; // Shallow production-boundary checks. These invoke returned handlers and effect // lifetimes; they do not claim browser layout, focus, or React StrictMode validation. @@ -136,7 +137,7 @@ function setup() { media: () => undefined, } as unknown as RelaySession; const close = vi.fn(); - function render() { + function render(navigation?: PageNavigation) { hooks.ref = hooks.index = hooks.effect = 0; const scoped = ThreadPanel({ session, @@ -146,6 +147,7 @@ function setup() { messageId: row.id, close, onOpenLink: () => false, + navigation, }); return ( scoped.type as ( @@ -193,6 +195,24 @@ it("allocates only in the committed effect and disposes each owned view across e h.unmount(); expect(h.view.dispose).toHaveBeenCalledTimes(2); }); +it("navigation retry allocates a fresh reader without presenting stale results", () => { + const h = setup(); + const navigation = linkedNavigation(); + h.render(navigation); + h.effects(); + h.render(navigation); + h.effects(); + expect(h.thread).toHaveBeenCalledTimes(1); + const retry = { ...navigation, signal: new AbortController().signal }; + const pending = h.render(retry); + expect( + elements(pending).some((e) => e.props.children === "Loading thread…"), + ).toBe(true); + h.effects(); + expect(h.view.dispose).toHaveBeenCalledTimes(1); + expect(h.thread).toHaveBeenCalledTimes(2); + expect(h.view.refresh).toHaveBeenCalledTimes(2); +}); it("allocation failure exposes an effective retry rather than leaving a spinner", () => { const h = setup(); h.thread.mockImplementationOnce(() => { @@ -354,13 +374,13 @@ it("the actual message reply button opens that message and retains the trigger f expect(open).toHaveBeenCalledExactlyOnceWith(row.id); }); -function messagesHarness() { +function messagesHarness(navigation?: PageNavigation) { const h = setup(); h.render(); h.effects(); const child = elements(h.render()).find((e) => typeof e.type === "function"); if (!child) throw new Error("Missing thread messages"); - const props = child.props; + const props = { ...child.props, navigation }; const component = child.type as ( props: Record, ) => ReactElement; @@ -369,6 +389,10 @@ function messagesHarness() { hooks.states = []; let scrollTop = 0; const element = { + querySelector: vi.fn< + () => { getBoundingClientRect(): { top: number; height: number } } | null + >(() => null), + getBoundingClientRect: () => ({ top: 100 }), clientHeight: 600, scrollHeight: 4000, get scrollTop() { @@ -419,6 +443,67 @@ function messagesHarness() { }, }; } + +function linkedNavigation() { + return { + entryId: "linked", + target: { + version: 1, + kind: "conversation", + scope: { viewer: "b".repeat(64), communityOrigin: "https://example.com" }, + channelId: "channel", + messageId: row.id, + }, + signal: new AbortController().signal, + complete: vi.fn(() => true), + resolve: vi.fn(() => true), + forSession: vi.fn(), + } satisfies PageNavigation; +} +it("waits for bounded history then reveals and acknowledges the exact linked message once", () => { + const navigation = linkedNavigation(); + const h = messagesHarness(navigation); + h.element.querySelector.mockReturnValue({ + getBoundingClientRect: () => ({ top: 1600, height: 60 }), + }); + h.snapshot.canLoadMore = true; + h.render(); + h.effects(); + expect(navigation.complete).not.toHaveBeenCalled(); + expect(h.element.scrollTop).toBe(0); + h.snapshot.canLoadMore = false; + h.render(); + h.effects(); + expect(h.element.querySelector).toHaveBeenCalledWith( + `[data-message-id="${row.id}"]`, + ); + expect(h.element.scrollTop).toBe(1230); + expect(navigation.complete).toHaveBeenCalledExactlyOnceWith({ + status: "opened", + }); + h.element.scrollHeight = 4800; + h.render(); + h.effects(); + expect(h.element.scrollTop).toBe(1230); + expect(navigation.complete).toHaveBeenCalledTimes(1); +}); +it("does not acknowledge missing or cancelled linked targets as opened", () => { + const navigation = linkedNavigation(); + const h = messagesHarness(navigation); + h.render(); + h.effects(); + expect(navigation.complete).toHaveBeenCalledExactlyOnceWith({ + status: "failed", + reason: "not-found", + }); + const aborted = new AbortController(); + aborted.abort(); + const cancelled = { ...linkedNavigation(), signal: aborted.signal }; + const next = messagesHarness(cancelled); + next.render(); + next.effects(); + expect(cancelled.complete).not.toHaveBeenCalled(); +}); it("positions after successful history loading, then follows live replies without another read", () => { const h = messagesHarness(); h.snapshot.status = "loading"; diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 80a01d32..7d94bea5 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { X } from "lucide-react"; import type { ConversationExtensions } from "../conversation/contracts"; +import type { PageNavigation } from "../navigation/service"; import type { RelaySession } from "../relay/session"; import type { ThreadView } from "../relay/threads"; import { useRowProfiles } from "../relay/react"; @@ -19,6 +20,7 @@ import { useReading } from "./use-reading"; import { messageViewKey } from "./view-key"; export type ThreadPanelProps = { + navigation?: PageNavigation | undefined; extensions?: ConversationExtensions | undefined; session: RelaySession; scope: string; @@ -54,11 +56,21 @@ function OwnedThreadPanel({ close, onOpenLink, canOpenLink, + navigation, }: ThreadPanelProps) { - const [view, setView] = useState(); - const [error, setError] = useState(); + const [owned, setOwned] = useState<{ + navigation: PageNavigation | undefined; + view?: ThreadView; + error?: string; + }>(); + const view = owned?.navigation === navigation ? owned?.view : undefined; + const error = owned?.navigation === navigation ? owned?.error : undefined; const [attempt, setAttempt] = useState(0); const closeButton = useRef(null); + useEffect(() => { + if (error) + navigation?.complete({ status: "failed", reason: "unavailable" }); + }, [error, navigation]); useEffect(() => { closeButton.current?.focus(); }, []); @@ -66,15 +78,14 @@ function OwnedThreadPanel({ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery after view allocation fails. useEffect(() => { try { - const owned = session.thread(channelId, messageId); - setError(undefined); - setView(owned); - void owned.refresh(); - return () => owned.dispose(); + const view = session.thread(channelId, messageId); + setOwned({ navigation, view }); + void view.refresh(); + return () => view.dispose(); } catch (error) { - setError(String(error)); + setOwned({ navigation, error: String(error) }); } - }, [session, channelId, messageId, attempt]); + }, [session, channelId, messageId, attempt, navigation]); return (