diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 72c2f776..cc5c97ca 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 530760c4..e979998c 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,17 @@ 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 or migrate legacy -Buzz links.** Message-addressed conversations show the selected verified row in a -bounded detail surface, ignoring optional `threadRootId` hints. Completion requires -the exact row to be visible and focused; unavailable targets never fall back to the -channel head. Ingress adapters must reuse this validated target/completion lifecycle. +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. Ingress adapters must reuse this +validated target/completion lifecycle. 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 +299,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. @@ -311,6 +349,15 @@ same `registerTool` contract. No page imports their implementations. Optional nu keyboard order stable across asynchronous activation and re-enable. Mentions uses `-10` to retain its position before default-order tools such as Emoji. +Links, channel references, selected mentions and custom emoji render through shared +message components directly in the editable draft. Display tokens retain the exact authored source; +copying and sending preserve that source. Arrow keys and deletion open adjacent +links for ordinary text editing. Partially deleting a link keeps it plain during +the editing session; double-click selects the link and triple-click selects its +whole paragraph. The host owns source offsets, plain-text paste, composition, undo and +selected recipient metadata. Token renderers are display-only while editing. +Names pasted as text never create notification intent. + Tools receive `insertText`, `insertMention({ pubkey, name })` and `focus` commands. Mention insertion atomically records visible text and exact notification intent; `true` means the edit was accepted, **not** that membership or delivery succeeded. diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 73823f3e..1f9860e9 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 fdcca17f..97a6c53b 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,6 +1,10 @@ import { useChannelPanels } from "./useChannelPanels"; import type { PageNavigation } from "../../features/navigation/service"; import type { Navigation } from "../../features/navigation/controller"; +import { + buzzLinkTarget, + isBuzzLink, +} from "../../features/navigation/buzz-links"; import { UnreadBadge, UnreadOptions } from "./UnreadBadge"; import { SidebarUnread } from "./SidebarUnread"; import type { ConversationExtensions } from "../../features/conversation/contracts"; @@ -379,6 +383,23 @@ function ChannelWorkspace({ }, [currentId, showingThread?.navigation]); const openLink = useCallback( (url: string) => { + if (isBuzzLink(url)) { + if (!navigator || !viewer) return false; + const target = buzzLinkTarget(url, { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }); + if (!target) return false; + if (target.kind === "conversation" && target.messageId) + threadTrigger.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setThread(undefined); + open(undefined); + void navigator.open(target); + return true; + } const candidate = panels.resolve(url); const context = linkContext.current; if (context.channelId && candidate) { @@ -397,7 +418,7 @@ function ChannelWorkspace({ } return false; }, - [panels, open, select], + [panels, open, select, navigator, viewer, scope], ); const panelActive = () => { const connection = relay.snapshot(); diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 8e4959a4..ec42f01c 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -21,11 +21,14 @@ import * as projects from "./projects"; import workflowsManifest from "./workflows/manifest.json"; import * as workflows from "./workflows"; 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..3f6b0844 --- /dev/null +++ b/src/bundled/links/InlineLink.test.tsx @@ -0,0 +1,163 @@ +import { expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { targetLink } from "../../features/navigation/targets"; +import { InlineLink, LinkLabel, linkKind } from "./InlineLink"; +import { + LinkLabelContext, + LinkContentContext, +} from "../../features/conversation/LinkLabelContext"; + +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).toContain(`href="${href}"`); +}); + +it.each([44, 45, 46, 200])( + "caps raw URL labels at 45 characters (source length %i)", + (length) => { + const href = "https://figma.com/design/".padEnd(length, "a"); + const markup = renderToStaticMarkup(); + const text = markup.replace(/<[^>]+>/g, ""); + expect(markup).toContain(`href="${href}"`); + expect(text).toBe(length <= 45 ? href : `${href.slice(0, 44)}…`); + }, +); + +it("preserves authored labels on long URLs", () => { + const href = `https://figma.com/design/${"a".repeat(100)}`; + const label = "View the full design and all of the discussion notes"; + const markup = renderToStaticMarkup( + {label}, + ); + expect(markup.replace(/<[^>]+>/g, "")).toBe(label); + expect(markup).toContain(`href="${href}"`); +}); + +it("truncates a Unicode URL when Markdown encodes its destination", () => { + const label = + "https://www.figma.com/design/example/Builderlab-—-Branding?node-id=1119-21207"; + const href = new URL(label).href; + const markup = renderToStaticMarkup( + + + + + , + ); + expect(markup.replace(/<[^>]+>/g, "")).toBe(`${label.slice(0, 44)}…`); +}); + +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..b7696989 --- /dev/null +++ b/src/bundled/links/InlineLink.tsx @@ -0,0 +1,171 @@ +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); + // Markdown encodes Unicode in destinations while retaining it in visible text. + const rawDestination = + label === href || + (linkKind(label) !== null && new URL(label).href === new URL(href).href); + const Icon = icons[kind]; + if (content !== undefined && !rawDestination) + return ( + + + ); + // Shorten only raw destinations; authored labels and resolved channel names stay intact. + if (rawDestination) { + const characters = Array.from(label); + if (characters.length > 45) label = `${characters.slice(0, 44).join("")}…`; + } + // Keep the icon with the start of its label. + 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.test.tsx b/src/features/conversation/BuzzLinkPreview.test.tsx new file mode 100644 index 00000000..e47db2a5 --- /dev/null +++ b/src/features/conversation/BuzzLinkPreview.test.tsx @@ -0,0 +1,253 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { BuzzLinkPreview } from "./BuzzLinkPreview"; +import type { RelaySession } from "../relay/session"; +import type { ThreadSnapshot, ThreadView } from "../relay/threads"; +import type { ChannelMessage } from "../relay/contracts"; + +// Shallow production-boundary checks. These invoke returned handlers and effect +// lifetimes; they do not claim browser layout, focus, or React StrictMode validation. +const hooks = vi.hoisted(() => ({ + refs: [] as { current: unknown }[], + ref: 0, + states: [] as unknown[], + index: 0, + effects: [] as { + deps: readonly unknown[]; + cleanup?: (() => void) | undefined; + }[], + effect: 0, + pending: [] as (() => void)[], +})); +vi.mock("react", async (original) => ({ + ...(await original()), + useRef(initial: unknown) { + const index = hooks.ref++; + hooks.refs[index] ??= { current: initial }; + return hooks.refs[index]; + }, + useState(initial: unknown) { + const index = hooks.index++; + if (!(index in hooks.states)) hooks.states[index] = initial; + return [ + hooks.states[index], + (next: unknown) => { + hooks.states[index] = + typeof next === "function" ? next(hooks.states[index]) : next; + }, + ]; + }, + useEffect(create: () => (() => void) | undefined, deps: readonly unknown[]) { + const index = hooks.effect++; + const old = hooks.effects[index]; + if (!old || deps.some((value, i) => value !== old.deps[i])) + hooks.pending.push(() => { + old?.cleanup?.(); + hooks.effects[index] = { deps, cleanup: create() }; + }); + }, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), +})); +beforeEach(() => + Object.assign(hooks, { + refs: [], + ref: 0, + states: [], + index: 0, + effects: [], + effect: 0, + pending: [], + }), +); +function elements(node: ReactNode): ReactElement>[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement>(node)) return []; + return [node, ...elements(node.props.children as ReactNode)]; +} +function text(node: ReactNode) { + return elements(node) + .filter((e) => e.props.role === "status") + .map((e) => e.props.children) + .join(""); +} +const root: ChannelMessage = { + id: "a".repeat(64), + channelId: "channel", + authorId: "b".repeat(64), + content: "reconciled body", + createdAt: 1, + mentions: [], + participants: [], + attachments: [], + reactions: [], + replyCount: 0, +}; +function setup(snapshot: ThreadSnapshot, messageId = root.id) { + Object.assign(hooks, { + refs: [], + ref: 0, + states: [], + index: 0, + effects: [], + effect: 0, + pending: [], + }); + const view = { + snapshot: () => snapshot, + subscribe: () => () => {}, + refresh: vi.fn(async () => {}), + loadMore: vi.fn(async () => {}), + dispose: vi.fn(), + } satisfies ThreadView; + const thread = vi.fn(() => view); + const session = { + thread, + profiles: { + subscribe: () => () => {}, + snapshot: () => new Map(), + ensure: vi.fn(async () => {}), + }, + channels: { + subscribeList: () => () => {}, + list: () => ({ channels: [{ id: "channel", name: "General" }] }), + }, + media: () => undefined, + } as unknown as RelaySession; + hooks.ref = hooks.index = hooks.effect = 0; + BuzzLinkPreview({ + session, + channelId: "channel", + messageId, + }); + for (const effect of hooks.pending.splice(0)) effect(); + // Re-render the outer component now that the effect allocated the view, then + // drive the inner PreviewContent with the shared hook registers. + hooks.ref = hooks.index = hooks.effect = 0; + const rendered = BuzzLinkPreview({ + session, + channelId: "channel", + messageId, + }) as ReactElement; + const child = elements(rendered).find((e) => typeof e.type === "function"); + if (!child) throw new Error("Missing preview content"); + hooks.ref = hooks.index = hooks.effect = 0; + const render = child.type as (props: unknown) => ReactElement; + const tree = render(child.props); + for (const effect of hooks.pending.splice(0)) effect(); + return { tree, thread }; +} +const base: ThreadSnapshot = { + status: "idle", + root: undefined, + replies: [], + error: undefined, + canLoadMore: false, + limited: false, +}; +it("does not paint the seed root until the snapshot reconciles edits and deletions", () => { + // A seeded but still-loading view exposes the raw root; a cached deletion or + // edit is a different event that only folds in once the read reaches "ready". + const seeded = setup({ + ...base, + status: "loading", + root, + canLoadMore: true, + }).tree; + expect(text(seeded)).toBe("Loading message…"); + expect(elements(seeded).some((e) => e.type === "strong")).toBe(false); + const idleSeed = setup({ ...base, status: "idle", root }).tree; + expect(text(idleSeed)).toBe("Loading message…"); + const ready = setup({ ...base, status: "ready", root }).tree; + expect(elements(ready).some((e) => e.type === "strong")).toBe(true); +}); +it("reports terminal unavailability instead of loading forever", () => { + // purge() on a denied or revoked channel publishes idle + an error string, + // never status "error"; the card must still stop. + const denied = setup({ + ...base, + status: "idle", + error: "This channel is no longer available.", + }).tree; + expect(text(denied)).toBe("Message preview unavailable."); + const failed = setup({ + ...base, + status: "error", + error: "read failed", + }).tree; + expect(text(failed)).toBe("Message preview unavailable."); + const exhausted = setup({ + ...base, + status: "ready", + root: undefined, + }).tree; + expect(text(exhausted)).toBe("Message preview unavailable."); +}); +it("keeps loading while a read is genuinely in flight", () => { + const loading = setup({ + ...base, + status: "loading", + canLoadMore: true, + }).tree; + expect(text(loading)).toBe("Loading message…"); +}); +it("renders the exact target independently of bounded thread replies", () => { + const target = { + ...root, + id: "c".repeat(64), + content: "reply beyond bounded traversal", + threadRootId: root.id, + }; + const result = setup( + { + ...base, + status: "ready", + root, + target, + targetStatus: "ready", + }, + target.id, + ); + expect(result.thread).toHaveBeenCalledWith("channel", target.id, { + exact: true, + }); + expect( + elements(result.tree).some( + (element) => element.props.children === "reply beyond bounded traversal", + ), + ).toBe(true); +}); +it("renders a reconciled exact target when its thread root is unavailable", () => { + const target = { + ...root, + id: "c".repeat(64), + content: "reply with unavailable root", + threadRootId: root.id, + }; + const result = setup( + { + ...base, + status: "error", + error: "Thread root is unavailable.", + target, + targetStatus: "ready", + }, + target.id, + ); + expect( + elements(result.tree).some( + (element) => element.props.children === "reply with unavailable root", + ), + ).toBe(true); +}); +it("fails safely when an exact target timestamp is outside Date range", () => { + const target = { ...root, createdAt: 8_640_000_000_001 }; + const result = setup({ + ...base, + status: "ready", + root, + target, + targetStatus: "ready", + }); + expect(text(result.tree)).toBe("Message preview unavailable."); +}); diff --git a/src/features/conversation/BuzzLinkPreview.tsx b/src/features/conversation/BuzzLinkPreview.tsx new file mode 100644 index 00000000..f81b21e6 --- /dev/null +++ b/src/features/conversation/BuzzLinkPreview.tsx @@ -0,0 +1,144 @@ +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, { exact: true }); + 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 target = + snapshot.target?.id === messageId ? snapshot.target : undefined; + const message = + target ?? + [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, + ); + const authorId = message?.authorId; + useEffect(() => { + if (authorId) + void session.profiles.ensure([authorId], "background").catch(() => {}); + }, [session, authorId]); + // Only paint reconciled content: a ready exact target is independent of its + // thread root, while an idle/loading root seed is still the raw event. + // A terminal state (read error, denied/interrupted purge, or an exhausted + // read that never found the target) fails honestly instead of loading forever. + const stopped = + snapshot.status === "error" || + snapshot.targetStatus === "error" || + snapshot.targetStatus === "unavailable" || + (snapshot.status === "idle" && snapshot.error !== undefined) || + (snapshot.status === "ready" && !message && !snapshot.canLoadMore); + const ready = target + ? snapshot.targetStatus === "ready" + : snapshot.status === "ready"; + if (!ready || !message) + return ( + + {stopped ? "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); + if (!Number.isFinite(date.getTime())) + return Message preview unavailable.; + 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/ComposerCompletions.tsx b/src/features/conversation/ComposerCompletions.tsx index cdb9ed45..b13e0386 100644 --- a/src/features/conversation/ComposerCompletions.tsx +++ b/src/features/conversation/ComposerCompletions.tsx @@ -1,3 +1,4 @@ +import type { ComposerInputElement } from "../messages/composer-dom"; import { createPortal } from "react-dom"; import { useCompletionPosition } from "./useCompletionPosition"; import { @@ -36,7 +37,7 @@ export function ComposerCompletions({ }: CompletionContext & { registry: ContributionReader; editor: CompletionEditor; - input: RefObject; + input: RefObject; replace( edit: CompletionEdit, query: CompletionQuery, @@ -94,7 +95,7 @@ function OwnedCompletion({ query: CompletionQuery; observation: ComposerObservation; editor: CompletionEditor; - input: RefObject; + input: RefObject; replace( edit: CompletionEdit, query: CompletionQuery, 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..6b3bc43a --- /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..37dbcabb --- /dev/null +++ b/src/features/conversation/MessageLink.tsx @@ -0,0 +1,184 @@ +import { + useRef, + 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, isBuzzLink } 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, + interactive = true, +}: { + url: string; + children?: ReactNode; + registry: ContributionReader | undefined; + onOpenLink(url: string): boolean; + label?: string | undefined; + session?: RelaySession | undefined; + scope?: string | undefined; + interactive?: boolean; +}) { + 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 trigger = useRef(null); + const internal = isBuzzLink(url); + 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(); + if (trigger.current?.isConnected) + trigger.current.focus({ preventScroll: true }); + 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(); + if (trigger.current?.isConnected) + trigger.current.focus({ preventScroll: true }); + const opened = onOpenLink(url); + setUnavailable(!opened); + if (opened) setPreviewOpen(false); + } + } + : undefined, + }; + const anchor = (entry?: Contribution) => { + const Content = entry?.component; + const content = Content ? ( + + ) : ( + (children ?? label ?? url) + ); + const element = interactive ? ( + + {content} + + ) : ( + + {content} + + ); + return interactive && 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 ac7b47b6..98e83dd9 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -51,6 +51,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; @@ -59,6 +67,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/conversation/useCompletionEditor.ts b/src/features/conversation/useCompletionEditor.ts index f3414a49..2f605101 100644 --- a/src/features/conversation/useCompletionEditor.ts +++ b/src/features/conversation/useCompletionEditor.ts @@ -1,3 +1,4 @@ +import type { ComposerInputElement } from "../messages/composer-dom"; import { useLayoutEffect, useCallback, @@ -10,7 +11,7 @@ import type { ComposerObservation } from "./contracts"; /** One synchronous invalidation owner; stale React closures cannot revive an edit. */ export function useCompletionEditor( - input: RefObject, + input: RefObject, enabled: boolean, ) { const [observation, setObservation] = useState(); @@ -19,7 +20,7 @@ export function useCompletionEditor( const last = useRef(undefined); const composing = useRef(false); const keys = useRef< - ((event: KeyboardEvent) => boolean) | undefined + ((event: KeyboardEvent) => boolean) | undefined >(undefined); const invalidate = useCallback(() => { ++revision.current; diff --git a/src/features/conversation/useCompletionPosition.ts b/src/features/conversation/useCompletionPosition.ts index b671d0ef..3ade3d79 100644 --- a/src/features/conversation/useCompletionPosition.ts +++ b/src/features/conversation/useCompletionPosition.ts @@ -1,9 +1,10 @@ +import type { ComposerInputElement } from "../messages/composer-dom"; import { useLayoutEffect, useRef, type RefObject } from "react"; /** Portal positioning belongs to the host, not provider previews. The available * visual viewport bounds the menu even inside clipped/narrow conversation panels. */ export function useCompletionPosition( - input: RefObject, + input: RefObject, widthRatio = 1, ) { const popup = useRef(null); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 715c5c0d..464b441b 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -485,10 +485,10 @@ function Timeline({ /> ) : ( , + "onChange" +> & { + ref: RefObject; + value: string; + disabled: boolean; + placeholder: string; + maxLength: number; + onUndo(redo: boolean): void; +}; + +/** The DOM owns native editing/IME; React only owns the noneditable token contents. */ +export function EditableInput({ + ref, + value, + disabled, + placeholder, + maxLength, + onUndo, + decorations, + ...events +}: EditableInputProps & { decorations: readonly EditorDecoration[] }) { + const element = useRef(null); + const composing = useRef(false); + const pendingEdit = useRef(undefined); + const linkEdits = useRef({ + text: value, + links: [] as { start: number; end: number }[], + plain: [] as PlainLink[], + }); + const pendingSelection = + useRef>(undefined); + const current = useRef({ onUndo, maxLength, insertText }); + current.current = { onUndo, maxLength, insertText }; + const [revision, refresh] = useState(0); + const [hosts, setHosts] = useState< + { start: number; end: number; element: HTMLSpanElement }[] + >([]); + const shape = JSON.stringify( + decorations.map(({ start, end, editAsText }) => [start, end, editAsText]), + ); + useLayoutEffect(() => { + const root = element.current; + if (!root) return; + let last = { start: 0, end: 0, backward: false }; + const selection = () => editorSelection(root) ?? last; + Object.defineProperties(root, { + value: { + configurable: true, + get: () => editorText(root), + set: (text: string) => { + root.textContent = text; + }, + }, + selectionStart: { configurable: true, get: () => selection().start }, + selectionEnd: { configurable: true, get: () => selection().end }, + selectionDirection: { + configurable: true, + get: () => (selection().backward ? "backward" : "forward"), + }, + disabled: { + configurable: true, + get: () => root.getAttribute("aria-disabled") === "true", + }, + readOnly: { + configurable: true, + get: () => + root.getAttribute("aria-readonly") === "true" || + root.contentEditable === "false", + set: (value: boolean) => { + root.setAttribute("aria-readonly", String(value)); + root.contentEditable = String(!value && !root.disabled); + }, + }, + setSelectionRange: { + configurable: true, + value: (start: number, end: number, direction?: string) => { + pendingSelection.current = undefined; + last = { start, end, backward: direction === "backward" }; + setEditorSelection(root, start, end, last.backward); + }, + }, + }); + ref.current = root; + const focus = () => + setEditorSelection(root, last.start, last.end, last.backward); + root.addEventListener("focus", focus); + const select = () => { + if (!composing.current) normalizeTokenCaret(root); + highlightEditorSelection(root); + const next = editorSelection(root); + if (!next) return; + last = next; + root.dispatchEvent(new Event("select")); + }; + const before = (event: InputEvent) => { + if (event.inputType.startsWith("history")) { + event.preventDefault(); + current.current.onUndo(event.inputType === "historyRedo"); + return; + } + // Native edit commands (including WebKit's Delete menu action) can arrive + // without keydown. Do not spend the first deletion on our caret marker. + if ( + event.cancelable && + !event.isComposing && + !root.disabled && + !root.readOnly && + ["deleteContentBackward", "deleteContentForward"].includes( + event.inputType, + ) + ) { + normalizeTokenCaret(root); + const backward = event.inputType === "deleteContentBackward"; + if (editAdjacentLink(root, backward, false, false) === false) { + event.preventDefault(); + event.stopImmediatePropagation(); + if (root.selectionStart === root.selectionEnd) { + const caret = root.selectionStart; + const segments = new Intl.Segmenter(undefined, { + granularity: "grapheme", + }).segment(root.value); + const character = segments.containing(backward ? caret - 1 : caret); + if (!character) return; + root.setSelectionRange( + character.index, + character.index + character.segment.length, + ); + } + current.current.insertText("", event.inputType); + return; + } + } + const target = event.getTargetRanges?.()[0]; + if ( + target && + root.contains(target.startContainer) && + root.contains(target.endContainer) && + !event.isComposing + ) { + const start = sourceOffset( + root, + target.startContainer, + target.startOffset, + ); + const end = sourceOffset(root, target.endContainer, target.endOffset); + // Expose the browser's real deletion range to mention tracking. + if (event.inputType.startsWith("delete") && start !== end) + root.setSelectionRange(start, end); + } + if ( + event.data && + root.value.length - + (root.selectionEnd - root.selectionStart) + + event.data.length > + current.current.maxLength + ) + event.preventDefault(); + if (!event.defaultPrevented) + pendingEdit.current = { + text: root.value, + start: root.selectionStart, + end: root.selectionEnd, + }; + }; + const openToken = (event: MouseEvent) => { + const target = + event.target instanceof Element + ? event.target.closest("[data-source]") + : null; + if (root.disabled || root.readOnly || !target || !root.contains(target)) + return; + const index = [...root.childNodes].indexOf(target); + const start = sourceOffset(root, root, index); + const end = start + editorText(target).length; + root.focus(); + root.setSelectionRange(start, end); + }; + const placeAfterToken = (event: MouseEvent) => { + if (root.disabled || root.readOnly) return; + if (event.detail >= 3) { + // Browsers stop paragraph selection at contenteditable=false islands. + const position = root.selectionStart; + const start = + position === 0 ? 0 : root.value.lastIndexOf("\n", position - 1) + 1; + const newline = root.value.indexOf("\n", position); + root.setSelectionRange( + start, + newline < 0 ? root.value.length : newline + 1, + ); + return; + } + const token = + event.target instanceof Element + ? event.target.closest("[data-source]") + : null; + if ( + !token?.parentNode || + !root.contains(token) || + root.disabled || + root.readOnly || + event.shiftKey || + event.detail !== 1 || + !root.ownerDocument.getSelection()?.isCollapsed + ) + return; + const index = [...token.parentNode.childNodes].indexOf(token); + const end = + sourceOffset(root, token.parentNode, index) + editorText(token).length; + root.focus(); + root.setSelectionRange(end, end); + }; + root.addEventListener("click", placeAfterToken); + root.addEventListener("dblclick", openToken); + root.addEventListener("beforeinput", before, true); + root.ownerDocument.addEventListener("selectionchange", select); + return () => { + root.removeEventListener("focus", focus); + root.removeEventListener("click", placeAfterToken); + root.removeEventListener("dblclick", openToken); + root.removeEventListener("beforeinput", before, true); + root.ownerDocument.removeEventListener("selectionchange", select); + ref.current = null; + }; + }, [ref]); + // biome-ignore lint/correctness/useExhaustiveDependencies: only source/ranges rebuild the editing DOM; content changes stay in portals. + useLayoutEffect(() => { + const root = element.current; + if (!root || composing.current) return; + const previous = linkEdits.current; + const plain = updatePlainLinks( + previous.text, + value, + previous.links, + previous.plain, + pendingEdit.current, + ); + pendingEdit.current = undefined; + const visible = decorations.filter( + (item) => + !item.editAsText || + !plain.some( + (range) => item.start < range.end && item.end > range.start, + ), + ); + linkEdits.current = { + text: value, + plain, + links: visible + .filter((item) => item.editAsText) + .map(({ start, end }) => ({ start, end })), + }; + const selection = editorSelection(root); + const fragment = root.ownerDocument.createDocumentFragment(); + const next = []; + let offset = 0; + const appendText = (text: string, afterToken: boolean) => { + if (!afterToken) { + fragment.append(root.ownerDocument.createTextNode(text)); + return; + } + // A real text position after an inline object gives WebKit a caret box. + // editorText/source offsets exclude this rendering-only boundary character. + const span = root.ownerDocument.createElement("span"); + span.dataset.editorText = ""; + span.append(root.ownerDocument.createTextNode(`\u200B${text}`)); + fragment.append(span); + }; + for (const decoration of visible) { + appendText(value.slice(offset, decoration.start), next.length > 0); + const host = root.ownerDocument.createElement("span"); + host.contentEditable = "false"; + host.dataset.source = value.slice(decoration.start, decoration.end); + if (decoration.editAsText) host.dataset.editAsText = ""; + host.className = styles.token ?? ""; + fragment.append(host); + next.push({ + start: decoration.start, + end: decoration.end, + element: host, + }); + offset = decoration.end; + } + appendText(value.slice(offset), next.length > 0); + const tail = root.ownerDocument.createElement("br"); + tail.dataset.placeholder = "true"; + fragment.append(tail); + root.replaceChildren(fragment); + pendingSelection.current = selection; + setHosts(next); + // Only changes to source/ranges rebuild editing DOM; profile/icon updates stay in portals. + }, [value, shape, revision]); + // biome-ignore lint/correctness/useExhaustiveDependencies: restore once after the portal hosts have committed their children. + useLayoutEffect(() => { + const root = element.current; + const selection = pendingSelection.current; + pendingSelection.current = undefined; + // Portals now contain their rendered link/mention. Restore against its final + // layout, without overriding a newer selection from a composer command. + if (root && selection && root.ownerDocument.activeElement === root) + setEditorSelection( + root, + Math.min(root.value.length, selection.start), + Math.min(root.value.length, selection.end), + selection.backward, + ); + if ( + root?.hasAttribute("data-single-emoji") && + root.selectionStart === root.value.length && + root.selectionEnd === root.value.length + ) + root.scrollTop = Math.max(0, root.scrollHeight - root.clientHeight); + }, [hosts]); + + function insertText(text: string, inputType = "insertFromPaste") { + const root = element.current; + if (!root || root.disabled || root.readOnly) return; + if (!composing.current) normalizeTokenCaret(root); + text = text.replace(/\r\n?/g, "\n"); + if ( + root.value.length - + (root.selectionEnd - root.selectionStart) + + text.length > + current.current.maxLength + ) + return; + const event = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType, + data: text, + }); + if (!root.dispatchEvent(event)) return; + const selection = root.ownerDocument.getSelection(); + if (!selection?.rangeCount) return; + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return; + range.deleteContents(); + const node = root.ownerDocument.createTextNode(text); + range.insertNode(node); + selection.setBaseAndExtent(node, text.length, node, text.length); + root.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType, data: text }), + ); + return true; + } + return ( + <> + {/* biome-ignore lint/a11y/useSemanticElements: rich content needs a contenteditable textbox. */} +
{ + event.preventDefault(); + const root = element.current; + if (!root || root.disabled || root.readOnly) return; + let text = event.clipboardData.getData("text/plain"); + let link = false; + messageLinkParts(text, undefined, (start, end) => { + if (start === 0 && end === text.length) link = true; + }); + const reuseSpace = link && root.value[root.selectionEnd] === " "; + if ( + link && + !reuseSpace && + root.value.length - + (root.selectionEnd - root.selectionStart) + + text.length < + maxLength + ) + text += " "; + if (insertText(text) && reuseSpace) { + const caret = root.selectionStart + 1; + root.setSelectionRange(caret, caret); + } + }} + onCopy={(event) => { + const root = element.current; + if (!root) return; + event.preventDefault(); + event.clipboardData.setData( + "text/plain", + root.value.slice(root.selectionStart, root.selectionEnd), + ); + }} + onCut={(event) => { + const root = element.current; + if (!root) return; + event.preventDefault(); + event.clipboardData.setData( + "text/plain", + root.value.slice(root.selectionStart, root.selectionEnd), + ); + insertText("", "deleteByCut"); + }} + onDrop={(event) => { + event.preventDefault(); + }} + onInput={(event) => { + const root = element.current; + if (root && root.value.length > maxLength) { + if (!composing.current) { + root.value = value; + root.setSelectionRange(value.length, value.length); + refresh((value) => value + 1); + } + return; + } + events.onInput?.(event as ReactInputEvent); + if (!composing.current && root?.value === value) + refresh((revision) => revision + 1); + }} + onKeyDown={(event) => { + if ( + composing.current || + event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229 + ) { + events.onKeyDown?.(event as KeyboardEvent); + return; + } + // A key can arrive before the native selectionchange notification. + if (element.current) normalizeTokenCaret(element.current); + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + ["z", "y"].includes(event.key.toLowerCase()) + ) { + event.preventDefault(); + onUndo(event.shiftKey || event.key.toLowerCase() === "y"); + return; + } + events.onKeyDown?.(event as KeyboardEvent); + const root = element.current; + if ( + root && + !root.disabled && + !root.readOnly && + !event.defaultPrevented && + ["ArrowLeft", "ArrowRight"].includes(event.key) + ) + if ( + editAdjacentLink( + root, + event.key === "ArrowLeft", + true, + event.shiftKey, + ) + ) { + event.preventDefault(); + return; + } + if ( + root && + !event.defaultPrevented && + !event.altKey && + ["Home", "End"].includes(event.key) + ) { + event.preventDefault(); + const selection = editorSelection(root); + const anchor = selection?.backward + ? root.selectionEnd + : root.selectionStart; + const focus = selection?.backward + ? root.selectionStart + : root.selectionEnd; + const end = root.value.indexOf("\n", focus); + const next = + event.key === "Home" + ? event.metaKey || event.ctrlKey + ? 0 + : focus === 0 + ? 0 + : root.value.lastIndexOf("\n", focus - 1) + 1 + : event.metaKey || event.ctrlKey || end < 0 + ? root.value.length + : end; + root.setSelectionRange( + event.shiftKey ? Math.min(anchor, next) : next, + event.shiftKey ? Math.max(anchor, next) : next, + event.shiftKey && next < anchor ? "backward" : "forward", + ); + return; + } + if ( + !event.defaultPrevented && + event.key === "Enter" && + !composing.current + ) { + event.preventDefault(); + insertText("\n", "insertLineBreak"); + } + }} + onCompositionStart={(event) => { + composing.current = true; + events.onCompositionStart?.( + event as CompositionEvent, + ); + }} + onCompositionEnd={(event) => { + composing.current = false; + const root = element.current; + if (root && root.value.length > maxLength) { + root.value = value; + root.setSelectionRange(value.length, value.length); + } + events.onCompositionEnd?.( + event as CompositionEvent, + ); + refresh((value) => value + 1); + }} + /> + {hosts.map((host) => { + const decoration = decorations.find( + (item) => item.start === host.start && item.end === host.end, + ); + return ( + decoration && + createPortal( + decoration.content, + host.element, + `${host.start}:${host.end}`, + ) + ); + })} + + ); +} diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 85acc855..f528c799 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -2,6 +2,7 @@ import { assert, afterEach, beforeEach, expect, it, vi } from "vitest"; import { isValidElement, type ReactNode, type ReactElement } from "react"; import { ComposerTools } from "../conversation/ComposerTools"; import type { ConversationExtensions } from "../conversation/contracts"; +import { RichComposerInput } from "./RichComposerInput"; import { MessageComposer } from "./MessageComposer"; import { isEmojiOnly, @@ -132,7 +133,9 @@ function mount( props: typeof scoped.props, ) => ReactElement> )(scoped.props); - const field = elements(tree).find((element) => element.type === "textarea"); + const field = elements(tree).find( + (element) => element.type === RichComposerInput, + ); if (field) { const ref = field.props.ref as { current: HTMLTextAreaElement }; const length = (field.props.value as string).length; @@ -145,7 +148,7 @@ function mount( return tree; }; const input = () => { - const field = elements(render()).find((e) => e.type === "textarea"); + const field = elements(render()).find((e) => e.type === RichComposerInput); if (!field) throw new Error("No composer input"); return field; }; @@ -237,8 +240,12 @@ it("keeps the draft on synchronous rejection and clears only after the outbox ac it("gives the channel and thread separate input/label identities, and gates unsupported writes", () => { const channel = mount().render(), thread = mount("root").render(); - const channelInput = elements(channel).find((e) => e.type === "textarea"); - const threadInput = elements(thread).find((e) => e.type === "textarea"); + const channelInput = elements(channel).find( + (e) => e.type === RichComposerInput, + ); + const threadInput = elements(thread).find( + (e) => e.type === RichComposerInput, + ); expect(threadInput?.props.id).not.toBe(channelInput?.props.id); expect(threadInput?.props.placeholder).toBe("Reply to thread"); expect(elements(thread).find((e) => e.type === "label")?.props.htmlFor).toBe( @@ -246,7 +253,7 @@ it("gives the channel and thread separate input/label identities, and gates unsu ); expect( elements(mount("root", "scope", false).render()).some( - (e) => e.type === "textarea", + (e) => e.type === RichComposerInput, ), ).toBe(false); }); @@ -389,12 +396,12 @@ it("keeps custom emoji shortcode text readable in the draft and sends it unchang const insertText = tools.props.insertText as (text: string) => boolean; expect(insertText(":party:")).toBe(true); expect(h.input().props.value).toBe(":party:"); - expect(h.input().props["data-custom-emoji-only"]).toBe(true); + expect(h.input().props["data-single-emoji"]).toBe(true); expect(insertText(":party:")).toBe(true); expect(h.input().props.value).toBe(":party::party:"); - expect( - elements(h.render()).filter((element) => element.type === "img"), - ).toHaveLength(2); + expect(h.input().props.emoji).toEqual([ + { shortcode: "party", url: "https://emoji.test/party.png" }, + ]); h.submit(); expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", @@ -408,11 +415,10 @@ it("renders a leading custom emoji inline when text follows it", () => { { shortcode: "bufo", url: "https://emoji.test/bufo.png" }, ]); h.type(":bufo:lakjsdlkjflakjsdf"); - expect(h.input().props["data-custom-emoji-only"]).toBeUndefined(); - expect(h.input().props["data-leading-custom-emoji"]).toBe(true); - expect( - elements(h.render()).filter((element) => element.type === "img"), - ).toHaveLength(1); + expect(h.input().props["data-single-emoji"]).toBeUndefined(); + expect(h.input().props.emoji).toEqual([ + { shortcode: "bufo", url: "https://emoji.test/bufo.png" }, + ]); h.submit(); expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 30e44ffa..0d031cbd 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -35,6 +35,8 @@ import type { } from "../conversation/contracts"; import { ComposerCompletions } from "../conversation/ComposerCompletions"; import { useCompletionEditor } from "../conversation/useCompletionEditor"; +import { RichComposerInput } from "./RichComposerInput"; +import type { ComposerInputElement } from "./composer-dom"; export type MessageComposerProps = { extensions?: ConversationExtensions | undefined; @@ -82,7 +84,34 @@ function Composer({ const draft = value.text; const valueRef = useRef(value); const caret = useRef(undefined); - const saveDraft = (next: MentionDraft) => { + const input = useRef(null); + const restoreSelection = useRef<{ start: number; end: number } | undefined>( + undefined, + ); + const compositionSaved = useRef(false); + const history = useRef<{ + past: { draft: MentionDraft; start: number; end: number }[]; + future: { draft: MentionDraft; start: number; end: number }[]; + }>({ past: [], future: [] }); + const saveDraft = ( + next: MentionDraft, + before?: { start: number; end: number }, + ) => { + if ( + next.text === valueRef.current.text && + JSON.stringify(next.recipients) === + JSON.stringify(valueRef.current.recipients) + ) + return; + if (!compositionSaved.current) + history.current.past.push({ + draft: valueRef.current, + start: before?.start ?? input.current?.selectionStart ?? 0, + end: before?.end ?? input.current?.selectionEnd ?? 0, + }); + if (completion.composing.current) compositionSaved.current = true; + history.current.past = history.current.past.slice(-100); + history.current.future = []; valueRef.current = next; updateDraft(next); writeView(scope, draftKey, next); @@ -90,8 +119,6 @@ function Composer({ const setDraft = (text: string) => saveDraft(editMentionDraft(valueRef.current, text)); const [error, setError] = useState(); - const [failedCustomEmoji, setFailedCustomEmoji] = useState(); - const [selection, setSelection] = useState({ start: 0, end: 0 }); const outbox = session.outbox; const emojiCatalog = useSyncExternalStore( session.emoji.subscribe, @@ -99,83 +126,15 @@ function Composer({ session.emoji.snapshot, ); const customEmojiOnly = customEmojiOnlySpans(draft, emojiCatalog.entries); - const leadingCustomEmoji = customEmojiOnly.length - ? { spans: [], end: 0 } - : leadingCustomEmojiSpans(draft, emojiCatalog.entries); - const customEmoji = customEmojiOnly.length - ? customEmojiOnly - : leadingCustomEmoji.spans; - const customEmojiSources = customEmoji.map(({ emoji, start, end }) => ({ - start, - end, - key: `${start}:${emoji.shortcode}`, - source: session.media(emoji.url), - })); - const showCustomEmoji = - !!customEmojiSources.length && - customEmojiSources.every( - ({ source }) => !!source && source !== failedCustomEmoji, - ); - const showCustomEmojiOnly = showCustomEmoji && customEmojiOnly.length > 0; - const showLeadingCustomEmoji = - showCustomEmoji && - !customEmojiOnly.length && - !!leadingCustomEmoji.spans.length; - const input = useRef(null); - const customEmojiMirror = useRef(null); - const customEmojiGroup = useRef(null); - const [customEmojiLayout, setCustomEmojiLayout] = useState({ - left: 0, - top: 0, - height: 48, - }); - useLayoutEffect(() => { - if (!showCustomEmojiOnly) return; - const group = customEmojiGroup.current; - const last = group?.children[customEmojiOnly.length - 1]; - if (!group || !last || !(last instanceof HTMLElement)) return; - const resize = () => { - const lineHeight = Number.parseFloat( - getComputedStyle(input.current ?? group).lineHeight, - ); - const next = { - left: Math.min( - last.offsetLeft + last.offsetWidth + 2, - Math.max(0, group.clientWidth - 2), - ), - top: last.offsetTop, - height: last.offsetTop + lineHeight, - }; - setCustomEmojiLayout((current) => - current.left === next.left && - current.top === next.top && - current.height === next.height - ? current - : next, - ); - }; - resize(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(resize); - observer.observe(group); - return () => observer.disconnect(); - }, [showCustomEmojiOnly, customEmojiOnly.length]); - useLayoutEffect(() => { - const element = input.current; - if ( - showCustomEmojiOnly && - element && - element.selectionStart === draft.length && - element.selectionEnd === draft.length - ) - element.scrollTop = Math.max( - 0, - customEmojiLayout.height - element.clientHeight, - ); - }, [showCustomEmojiOnly, customEmojiLayout.height, draft.length]); - const inlineEmojiGroup = useRef(null); - const inlinePrefixMeasure = useRef(null); - const [inlineTextIndent, setInlineTextIndent] = useState(0); + const customEmojiSpans = ( + customEmojiOnly.length + ? customEmojiOnly + : leadingCustomEmojiSpans(draft, emojiCatalog.entries).spans + ).filter(({ emoji }) => !!session.media(emoji.url)); + const largeEmojiDraft = usesLargeEmojiPresentation( + draft, + emojiCatalog.entries, + ); const edit = useRef(undefined); const completion = useCompletionEditor( input, @@ -201,51 +160,36 @@ function Composer({ if (outbox?.supports(9)) void session.emoji.ensure(); }, [session, outbox]); useLayoutEffect(() => { + if (restoreSelection.current) { + const { start, end } = restoreSelection.current; + input.current?.focus(); + input.current?.setSelectionRange(start, end); + restoreSelection.current = undefined; + return; + } if (caret.current === undefined) return; input.current?.focus(); input.current?.setSelectionRange(caret.current, caret.current); caret.current = undefined; }); - useLayoutEffect(() => { - if (!showLeadingCustomEmoji) { - setInlineTextIndent(0); + function undo(redo: boolean) { + if (disabled || input.current?.readOnly || completion.composing.current) return; - } - const emoji = inlineEmojiGroup.current; - const prefix = inlinePrefixMeasure.current; - if ( - !emoji || - !prefix || - typeof emoji.getBoundingClientRect !== "function" || - typeof prefix.getBoundingClientRect !== "function" - ) - return; - const resize = () => { - const next = - emoji.getBoundingClientRect().width - - prefix.getBoundingClientRect().width; - setInlineTextIndent((current) => - Math.abs(current - next) < 0.25 ? current : next, - ); - }; - resize(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(resize); - observer.observe(emoji); - observer.observe(prefix); - return () => observer.disconnect(); - }, [showLeadingCustomEmoji]); - useLayoutEffect(() => { - const mirror = customEmojiMirror.current; - if (mirror) mirror.scrollTop = input.current?.scrollTop ?? 0; - if (customEmojiGroup.current) - customEmojiGroup.current.style.transform = `translateY(-${input.current?.scrollTop ?? 0}px)`; - }); - function syncCustomEmojiScroll(element: HTMLTextAreaElement) { - if (customEmojiMirror.current) - customEmojiMirror.current.scrollTop = element.scrollTop; - if (customEmojiGroup.current) - customEmojiGroup.current.style.transform = `translateY(-${element.scrollTop}px)`; + const source = redo ? history.current.future : history.current.past; + const destination = redo ? history.current.past : history.current.future; + const next = source.pop(); + if (!next) return; + destination.push({ + draft: valueRef.current, + start: input.current?.selectionStart ?? 0, + end: input.current?.selectionEnd ?? 0, + }); + valueRef.current = next.draft; + updateDraft(next.draft); + writeView(scope, draftKey, next.draft); + caret.current = undefined; + restoreSelection.current = { start: next.start, end: next.end }; + completion.invalidate(); } function insert( text: string, @@ -323,7 +267,14 @@ function Composer({ ); } function send() { - if (disabled || !draft.trim() || !outbox) return; + if ( + disabled || + input.current?.readOnly || + input.current?.disabled || + !draft.trim() || + !outbox + ) + return; try { const id = threadRootId ? session.messages.reply( @@ -340,6 +291,7 @@ function Composer({ onSend?.(id); completion.invalidate(); setDraft(""); + history.current = { past: [], future: [] }; input.current?.focus(); setError(undefined); } catch (reason) { @@ -379,51 +331,39 @@ function Composer({ /> )}
-