From 9d7c63c84ee81c9d8f443eb9a9b9ba7536d54ea5 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:35:29 -0700 Subject: [PATCH 1/2] Show selected mentions as inline identity chips Use shared inert chips in the composer and distinguish selected namesakes without changing authored text or notification identity. Preserve draft editing and add clipboard, motion, and responsive coverage. Co-authored-by: Carl Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- docs/design-system-adoption.md | 6 +- docs/plugin-architecture.md | 10 +- .../composer/lab/ComposerCopyPreview.tsx | 4 + src/bundled/composer/lab/fixture.tsx | 10 +- src/features/conversation/ComposerTools.tsx | 22 +-- .../messages/MessageComposer.test.tsx | 115 +++++++++++-- src/features/messages/MessageComposer.tsx | 88 +--------- src/features/messages/Messages.module.css | 41 ----- src/features/messages/RichComposerInput.tsx | 149 ++++++++++++---- src/shared/design-system/AGENTS.md | 2 + src/shared/design-system/DESIGN.md | 28 +++ src/shared/design-system/styles/chips.css | 34 +++- .../design-system/ui/InlineChip.test.tsx | 95 ++++++++++ src/shared/design-system/ui/InlineChip.tsx | 27 ++- src/shared/identity/public-key.test.ts | 54 ++++++ src/shared/identity/public-key.ts | 44 +++++ tests/browser/completion-work.spec.mjs | 7 +- tests/browser/composer-links.spec.mjs | 34 ++-- tests/browser/conversation.spec.mjs | 33 ++-- tests/browser/mentions.spec.mjs | 162 +++++++++++++++--- tests/browser/messages.spec.mjs | 8 +- tests/browser/product-ui.spec.mjs | 35 ++++ tests/browser/typeahead.spec.mjs | 10 +- 23 files changed, 732 insertions(+), 286 deletions(-) create mode 100644 src/shared/design-system/ui/InlineChip.test.tsx create mode 100644 src/shared/identity/public-key.test.ts create mode 100644 src/shared/identity/public-key.ts diff --git a/docs/design-system-adoption.md b/docs/design-system-adoption.md index 2881ebbd0..fd5dcbfca 100644 --- a/docs/design-system-adoption.md +++ b/docs/design-system-adoption.md @@ -35,8 +35,10 @@ pill actions, consistent fields and shared states. another appearance preference. - Native disclosures remain for persisted channel groups and diagnostic content. They are disclosures, not application menus; their content and state remain local. -- Avatars, previews, links, mentions, thread summaries and recipient removal retain - their identity and navigation behavior. Shared appearance does not move their data. +- Avatars, previews, links, mentions and thread summaries retain their identity + and navigation ownership. Composer mentions use inert shared InlineChip rendering; + editing or deleting the mention removes its notification intent. Shared appearance + does not move their data. - Panel marks its surface separately from interactive components. Native product and plugin content inside it can still receive host defaults. - Legacy utility names remain available through the host bridge for existing diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 55eccab18..3455ffc29 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -374,9 +374,8 @@ chooser UI belongs in tool plugins: `bundled/emoji` and `bundled/mentions` use t same `registerTool` contract. No page imports their implementations. Optional numeric `order` (default zero, lower first; ties by contribution key) keeps visual and keyboard order stable across asynchronous activation and re-enable. Mentions uses -`-10` to retain its position before default-order tools such as Emoji. The host groups -negative-order tools with selected-recipient avatars, preserving DOM/keyboard order; -this is host layout, not a new plugin contract. +`-10` to retain its position before default-order tools such as Emoji. The host +renders tools in that order without a separate selected-recipient row. 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; @@ -397,8 +396,9 @@ presentation, never recipient resolution. Editing/pasting over an identity span removes its intent under the existing draft rules. **User intent outlives the tool that created it.** Disabling Mentions removes its -chooser, not selected recipients, their visible disclosure/removal controls, scoped -drafts or pending messages. The session still owns roster/profile data, membership +chooser, not selected recipients, their inline chips, scoped drafts or pending +messages. Editing or deleting a selected mention removes its notification intent; +there is no separate avatar removal control. The session still owns roster/profile data, membership checks, signing and publication/retry. Plugins remain trusted same-process code; revocable editor commands do not sandbox the session capabilities they receive. diff --git a/src/bundled/composer/lab/ComposerCopyPreview.tsx b/src/bundled/composer/lab/ComposerCopyPreview.tsx index 9128156d0..84245a982 100644 --- a/src/bundled/composer/lab/ComposerCopyPreview.tsx +++ b/src/bundled/composer/lab/ComposerCopyPreview.tsx @@ -47,6 +47,10 @@ export function ComposerCopyPreview() { groups={GROUPS} onValueChange={setValue} /> +

+ Try mentioning both people named Alice or both agents named Honey. + Adding the second namesake reveals a short public key on both chips. +

diff --git a/src/bundled/composer/lab/fixture.tsx b/src/bundled/composer/lab/fixture.tsx index 905ca07e5..279a13c2f 100644 --- a/src/bundled/composer/lab/fixture.tsx +++ b/src/bundled/composer/lab/fixture.tsx @@ -16,16 +16,24 @@ import { writeView } from "../../../shared/view-state"; const empty = () => () => {}; const alice = "a".repeat(64); const honey = "b".repeat(64); +const otherAlice = "c".repeat(64); +const otherHoney = "d".repeat(64); const profiles = new Map([ [alice, { name: "Alice" }], [honey, { name: "Honey", isAgent: true as const }], + [otherAlice, { name: "Alice" }], + [otherHoney, { name: "Honey", isAgent: true as const }], ]); const emptyList: readonly never[] = []; const emojiSnapshot = { status: "ready" as const, entries: emptyList }; const channelList = { status: "ready" as const, channels: [ - { id: "buzz-design", name: "buzz-design", members: [alice, honey] }, + { + id: "buzz-design", + name: "buzz-design", + members: [alice, honey, otherAlice, otherHoney], + }, ], }; const library = { status: "ready" as const, identities: [] }; diff --git a/src/features/conversation/ComposerTools.tsx b/src/features/conversation/ComposerTools.tsx index 9c0b7bf13..31ca88f13 100644 --- a/src/features/conversation/ComposerTools.tsx +++ b/src/features/conversation/ComposerTools.tsx @@ -1,10 +1,4 @@ -import { - useLayoutEffect, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import type { Contribution } from "../../plugins/contributions"; import type { ComposerTool, @@ -15,12 +9,9 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary"; export function ComposerTools({ registry, - renderLeading, ...props }: ComposerToolProps & { registry: ContributionReader; - /** Host layout for tools ordered before the default group; preserves DOM order. */ - renderLeading?: (tools: ReactNode) => ReactNode; }) { const tools = useSyncExternalStore( registry.subscribe, @@ -34,21 +25,14 @@ export function ComposerTools({ (a, b) => order(a) - order(b) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0), ); - const render = (tool: Contribution) => ( + return sorted.map((tool) => ( {tool.title} unavailable} > - ); - if (!renderLeading) return sorted.map(render); - return ( - <> - {renderLeading(sorted.filter((tool) => order(tool) < 0).map(render))} - {sorted.filter((tool) => order(tool) >= 0).map(render)} - - ); + )); } function OwnedTool({ tool, diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 9bf23e697..291ebec61 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -517,10 +517,13 @@ it.each([undefined, "root"])( h.unmount(); h = mount(options); expect( - screen.getByRole("button", { - name: `Remove mention Honey ${second.pubkey}`, + within(h.input()).getAllByRole("img", { + name: /^Person Honey, public key ending/, }), - ).toBeVisible(); + ).toHaveLength(2); + expect( + screen.queryByRole("region", { name: "Notification recipients" }), + ).not.toBeInTheDocument(); h.submit(); expect( (root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1), @@ -570,9 +573,7 @@ it.each([undefined, "root"])( }); expect(input).toHaveValue("@Honey can you see this is’s"); expect( - screen.getByRole("button", { - name: `Remove mention Honey ${first.pubkey}`, - }), + within(input).getByRole("img", { name: "Person Honey" }), ).toBeVisible(); h.submit(); expect( @@ -581,18 +582,91 @@ it.each([undefined, "root"])( }, ); -it("deleting a mention or removing its chip removes notification intent", async () => { +it("qualifies both namesakes retroactively without changing source and removes qualifiers with ambiguity", () => { + const h = mount(); + act(() => { + h.commands().insertMention(first); + }); + expect( + within(h.input()).getByRole("img", { name: "Person Honey" }), + ).toBeVisible(); + act(() => { + h.commands().insertMention(first); + }); + expect(h.input().textContent).not.toContain("npub"); + act(() => { + h.commands().insertMention({ ...second, name: "honey" }); + }); + expect(h.input()).toHaveValue("@Honey @Honey @honey "); + expect( + within(h.input()).getAllByRole("img", { + name: "Person Honey, public key ending c a j", + }), + ).toHaveLength(2); + expect( + within(h.input()).getByRole("img", { + name: "Person honey, public key ending 4 h u", + }), + ).toHaveTextContent("honey · npub…4hu"); + h.input().setSelectionRange(14, 20); + act(() => { + h.commands().insertText(""); + }); + expect(h.input()).toHaveValue("@Honey @Honey "); + expect(h.input().textContent).not.toContain("npub"); + h.submit(); + expect(h.messages.send).toHaveBeenCalledWith("channel", "@Honey @Honey ", [ + first.pubkey, + first.pubkey, + ]); +}); + +it.each([0, 7])( + "does not replay qualifier motion after removing at %i or restoring a destination draft", + (start) => { + const h = mount(); + act(() => { + h.commands().insertMention(first); + }); + act(() => { + h.commands().insertMention(second); + }); + expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(1); + h.input().setSelectionRange(start, start + 6); + act(() => { + h.commands().insertText(""); + }); + act(() => { + h.commands().insertMention(start === 0 ? first : second); + }); + expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(0); + h.retarget({ channelId: "other" }); + act(() => { + h.commands().insertMention(first); + }); + h.retarget({ channelId: "channel" }); + expect(h.input().querySelectorAll(".inline-chip-qualifier")).toHaveLength( + 2, + ); + expect(h.input().querySelectorAll("[data-reveal]")).toHaveLength(0); + }, +); + +it("replacing an inline mention with ordinary prose removes notification intent", async () => { const h = mount(); await h.user.click(screen.getByRole("button", { name: "First Honey" })); h.fill("no recipient now"); h.submit(); expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]); await h.user.click(screen.getByRole("button", { name: "First Honey" })); - await h.user.click( - screen.getByRole("button", { - name: `Remove mention Honey ${first.pubkey}`, - }), - ); + expect( + within(h.input()).getByRole("img", { name: "Person Honey" }), + ).toBeVisible(); + h.input().setSelectionRange(0, 6); + act(() => { + h.commands().insertText("Honey"); + }); + expect(within(h.input()).queryByRole("img")).not.toBeInTheDocument(); h.submit(); expect(h.messages.send.mock.calls[1]?.at(-1)).toEqual([]); }); @@ -1190,20 +1264,23 @@ it.each([undefined, "root"])( ]); expect(h.input()).toHaveValue("@Honey "); expect( - screen.getAllByRole("button", { name: /^Remove mention/ }), + within(h.input()).getAllByRole("img", { name: "Agent Honey" }), ).toHaveLength(1); + expect( + h.input().querySelector("button, a, [tabindex], [title]"), + ).toBeNull(); h.retarget({ channelId: "other" }); expect(h.input()).toHaveValue(""); h.retarget({ channelId: "channel" }); expect(h.input()).toHaveValue("@Honey "); h.submit(); expect(send.mock.calls.at(-1)?.at(-1)).toEqual([second.pubkey]); - fireEvent.click( - screen.getByRole("button", { - name: `Remove mention Honey ${second.pubkey}`, - }), - ); - expect(h.input()).toHaveValue("@Honey "); + h.input().setSelectionRange(0, 6); + act(() => { + h.commands().insertText("Honey"); + }); + expect(h.input()).toHaveValue("Honey "); + expect(within(h.input()).queryByRole("img")).not.toBeInTheDocument(); h.submit(); expect(send.mock.calls.at(-1)?.at(-1)).toEqual([]); expect(h.input()).toHaveValue(""); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 30799f768..f10e9c3bd 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,6 +1,5 @@ import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; -import { Avatar } from "../../shared/design-system/ui/Avatar"; import { useMentionAgents } from "../agents/mention-context"; import { enrollMentionedAgents } from "../agents/mention-enrollment"; import { knownAgentPubkeys } from "../agents/known"; @@ -514,26 +513,6 @@ function Composer({ open={(target) => onOpenLink?.(target) ?? false} /> ); - const renderLeadingTools = (tools: ReactNode) => ( -
- {tools} - {!!value.recipients.length && ( - - saveDraft({ - ...value, - recipients: value.recipients.filter( - (item) => item.pubkey !== pubkey, - ), - }) - } - /> - )} -
- ); if (!outbox?.supports(9)) return ( <> @@ -700,7 +679,6 @@ function Composer({ {extensions ? ( input.current?.focus()} /> - ) : ( - renderLeadingTools(null) - )} + ) : null} {trailingTool ?? (sessionConversation ? ( @@ -768,65 +744,3 @@ function Composer({ ); } - -/** Presentation stays host-owned even when the optional mention tool is disabled. */ -function RecipientAvatars({ - session, - recipients, - disabled, - remove, -}: { - session: RelaySession; - recipients: readonly MentionRecipient[]; - disabled: boolean; - remove(pubkey: string): void; -}) { - const profiles = useSyncExternalStore( - session.profiles.subscribe, - session.profiles.snapshot, - session.profiles.snapshot, - ); - const unique = [ - ...new Map(recipients.map((item) => [item.pubkey, item])).values(), - ]; - return ( -
- {unique.map((recipient) => { - const profile = profiles.get(recipient.pubkey); - return ( - remove(recipient.pubkey)} - icon={ - - } - /> - ); - })} -
- ); -} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 6f87068c1..a4234b438 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -308,47 +308,6 @@ html[data-keyboard-navigation] letter-spacing: var(--text-caption--letter-spacing); } -.composerLeadingTools { - display: flex; - align-items: center; - flex-wrap: wrap; - max-width: 100%; - gap: var(--space-half); - border-radius: var(--radius-control); -} -.composerLeadingTools:empty { - display: none; -} -.composerLeadingTools:has(.mentionRecipients) { - padding-inline: var(--space-half); - background: var(--affordance-accent); -} -.mentionRecipients { - display: flex; - flex-wrap: wrap; - align-items: center; - max-width: 100%; - gap: var(--space-half); -} -.mentionRecipientArtwork { - display: grid; - position: relative; - overflow: hidden; -} -.mentionRecipientRemove { - position: absolute; - inset: 0; - display: grid; - place-items: center; - background: var(--affordance-prominent); - color: var(--text-inverse); - opacity: 0; -} -.mentionRecipients - [data-mention-recipient]:not([data-disabled]):is(:hover, :focus-visible) - .mentionRecipientRemove { - opacity: 1; -} .mediaAttachments { display: grid; justify-items: start; diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 909c3b3fd..df1235841 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -1,3 +1,6 @@ +import { useLayoutEffect, useMemo, useRef } from "react"; +import { publicKeyLabels } from "../../shared/identity/public-key"; +import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import type { ConversationExtensions } from "../conversation/contracts"; import type { CustomEmoji } from "../relay/emoji"; import type { RelaySession } from "../relay/session"; @@ -33,6 +36,58 @@ export function RichComposerInput({ ...profiles.get(recipient.pubkey), name: recipient.name, }); + const qualifiers = useMemo(() => { + // Presentation groups authored names, never resolves notification identity. + const nameKeys = new Map>(); + for (const recipient of draft.recipients) { + const name = draft.text + .slice(recipient.start + 1, recipient.end) + .trim() + .toLowerCase(); + const keys = nameKeys.get(name) ?? new Set(); + keys.add(recipient.pubkey); + nameKeys.set(name, keys); + } + const result = new Map>(); + for (const [name, keys] of nameKeys) + if (keys.size > 1) result.set(name, publicKeyLabels(keys)); + return result; + }, [draft]); + const previous = useRef({ draft, qualifiers }); + // Composer is keyed by draft destination. Restored qualifiers start at rest. + const revealed = useRef( + new Set( + [...qualifiers].flatMap(([name, keys]) => + [...keys.keys()].map((key) => `${name}:${key}`), + ), + ), + ); + const reveal = useMemo(() => { + const reveal = new Set(); + if (previous.current.draft !== draft) { + for (const recipient of previous.current.draft.recipients) { + const name = previous.current.draft.text + .slice(recipient.start + 1, recipient.end) + .trim() + .toLowerCase(); + const identity = `${name}:${recipient.pubkey}`; + if ( + !previous.current.qualifiers.has(name) && + qualifiers.has(name) && + !revealed.current.has(identity) + ) + reveal.add(identity); + } + } + return reveal; + }, [draft, qualifiers]); + useLayoutEffect(() => { + previous.current = { draft, qualifiers }; + if (!draft.text) revealed.current.clear(); + // A qualifier shown at rest is already revealed too; either namesake may be removed. + for (const [name, keys] of qualifiers) + for (const key of keys.keys()) revealed.current.add(`${name}:${key}`); + }); const { tree, tooDeep } = scanMarkdown(draft.text); const literals: { start: number; end: number }[] = []; const pending = [tree]; @@ -97,35 +152,69 @@ export function RichComposerInput({ add(match.index, match.index + match[0].length); const decorations = ranges .sort((a, b) => a.start - b.start) - .map(({ start, end, mention, editAsText }) => ({ - start, - end, - editAsText: !!editAsText, - content: ( - false} - interactive={false} - /> - ), - })); + .map(({ start, end, mention, editAsText }) => { + const label = draft.text.slice(start + 1, end); + const qualifier = mention + ? qualifiers.get(label.trim().toLowerCase())?.get(mention) + : undefined; + return { + start, + end, + editAsText: !!editAsText, + content: mention ? ( + agent.pubkey === mention) + ? "agent" + : "person", + id: mention, + }} + face={{ + label, + loading: false, + resolved: true, + }} + qualifier={ + qualifier + ? { + text: `· ${qualifier}`, + reveal: reveal.has( + `${label.trim().toLowerCase()}:${mention}`, + ), + accessibleLabel: `public key ending ${qualifier.slice("npub…".length).split("").join(" ")}`, + } + : undefined + } + interactive={false} + /> + ) : ( + false} + interactive={false} + /> + ), + }; + }); return ; } diff --git a/src/shared/design-system/AGENTS.md b/src/shared/design-system/AGENTS.md index 6facc0a99..03f652c22 100644 --- a/src/shared/design-system/AGENTS.md +++ b/src/shared/design-system/AGENTS.md @@ -13,3 +13,5 @@ When wiring it into the app, use the shared components and tokens rather than th Integrate global styles deliberately through the host entry point instead of layering two resets, and keep the host appearance owner. The theme helper is viewer-only; app surfaces read appearance through the host. Run the root design:typecheck, design:check, design:test, and design:build scripts. + +Public-key display text uses `src/shared/identity/public-key.ts`, not hand-written slicing or a new visual component. See DESIGN.md “Public identity text”. Never format secret keys with it. diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index ac99e70a0..d478bb2ef 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -58,6 +58,34 @@ not density or emphasis. The caller supplies identity type from domain data, never a name or picture heuristic. `size="fill"` fills the owning layout’s available space. Shape clips the artwork, never the interactive focus target. +## Public identity text + +Public-key recognition labels use the shared pure formatter in +`src/shared/identity/public-key.ts`: `npub…` followed by the last three canonical +npub characters by default. This is text, not a component or a new identifier. +Use `formatPublicKey` for one label; use `publicKeyLabels` for an ambiguous group +so distinct keys receive a common suffix length that makes them distinguishable. +Inputs are public identity keys in hex, never credentials. Invalid inputs produce +no label; do not echo an invalid value as a fallback. Raw hex cannot establish +whether a caller supplied a secret: callers must use public-identity fields only. + +Keep the full key for routing, persistence, identity comparisons, and explicit key +copy actions. Short labels are recognition aids, not proof of identity. Full-key +inspection/export surfaces remain explicit exceptions. Existing surfaces are not +migrated automatically; new abbreviated public-key displays should reuse this rule. + +Hosts decide when ambiguity exists. Composer chips compare authored names without +case or surrounding whitespace and qualify every selected same-name identity, +including earlier selections. Repeating one identity is not ambiguous. Removing +the last distinct namesake restores name-only display. Qualifiers never change +message source, recipient spans, copyable message text, or notifications. Accessible +names spell out the public-key ending. No hover or picker change is implied. + +When an existing composer chip gains a qualifier, reveal only the qualifier with +shared settle motion; newly inserted chips appear at rest. Ordinary typing must +not replay the reveal. The suffix stays visible while a long name ellipsizes. +Removal is immediate and reduced motion disables the reveal animation. + ## Posture Buzz is a place where people build together and bring their agents into the room. Everyday surfaces stay quiet, crisp, and highly functional; character shows up in identity, guidance, transitions, and ceremony rather than in the chrome of ordinary work. Colour is signal, not decoration. When in doubt, the interface gets out of the way of the conversation. diff --git a/src/shared/design-system/styles/chips.css b/src/shared/design-system/styles/chips.css index a35ef6bfc..fd18735a2 100644 --- a/src/shared/design-system/styles/chips.css +++ b/src/shared/design-system/styles/chips.css @@ -14,7 +14,6 @@ display: inline-flex; max-width: 16rem; align-items: center; - gap: var(--space-chip-gap); border: 0; border-radius: var(--radius-chip); padding-inline: var(--space-chip-inset); @@ -67,6 +66,35 @@ white-space: nowrap; } + .inline-chip-qualifier { + display: grid; + grid-template-columns: 1fr; + flex: 0 0 auto; + overflow: hidden; + white-space: pre; + } + + .inline-chip-qualifier > span { + min-width: 0; + overflow: hidden; + } + + .inline-chip-qualifier[data-reveal] { + animation: inline-chip-qualifier-reveal var(--duration-settle) + var(--easing-settle); + } + + @keyframes inline-chip-qualifier-reveal { + from { + grid-template-columns: 0fr; + opacity: 0; + } + to { + grid-template-columns: 1fr; + opacity: 1; + } + } + /** * Kind icon, for the kinds whose label does not identify itself. * A person and an agent carry `@`, a channel carries `#`; a message and a @@ -76,6 +104,7 @@ * preference and keyboard zoom rather than freezing at one size. */ .inline-chip-icon { + margin-inline-end: var(--space-chip-gap); width: 1.1em; height: 1.1em; flex: 0 0 auto; @@ -87,6 +116,9 @@ .inline-chip { transition-duration: 0ms; } + .inline-chip-qualifier[data-reveal] { + animation: none; + } } /* The editor's wrapper around a chip node. Must not introduce its own box: diff --git a/src/shared/design-system/ui/InlineChip.test.tsx b/src/shared/design-system/ui/InlineChip.test.tsx new file mode 100644 index 000000000..b798bcdc5 --- /dev/null +++ b/src/shared/design-system/ui/InlineChip.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { chipFaces, resetChipFaces } from "../chips/faceResolver"; +import { InlineChip } from "./InlineChip"; + +const address = { kind: "person" as const, id: "a".repeat(64) }; +afterEach(() => { + cleanup(); + resetChipFaces(); + vi.restoreAllMocks(); +}); + +it("uses scoped faces without consulting or subscribing to the global registry", () => { + const get = vi.spyOn(chipFaces, "get"); + const subscribe = vi.spyOn(chipFaces, "subscribe"); + const { rerender, container } = render( + , + ); + expect(screen.getByRole("img", { name: "Person Alice" })).toHaveTextContent( + "@Alice", + ); + rerender( + , + ); + expect(screen.queryByText("@Alice")).not.toBeInTheDocument(); + expect( + screen.getByRole("img", { name: "Person Other community" }), + ).toBeVisible(); + expect(get).not.toHaveBeenCalled(); + expect(subscribe).not.toHaveBeenCalled(); + expect(container.querySelector("button, a, [tabindex], [title]")).toBeNull(); +}); + +it("retains registry-backed rendering for existing consumers", () => { + chipFaces.put(address, { label: "Alice", loading: false, resolved: true }); + render(); + expect(screen.getByRole("img", { name: "Person Alice" })).toBeVisible(); + act(() => { + chipFaces.put(address, { + label: "Renamed", + loading: false, + resolved: true, + }); + }); + expect(screen.getByRole("img", { name: "Person Renamed" })).toBeVisible(); +}); + +it("announces unresolved identities without pretending the fallback is a name", () => { + render( + , + ); + expect(screen.getByRole("img", { name: "Unresolved agent" })).toHaveAttribute( + "data-state", + "unresolved", + ); +}); + +it("keeps an accessible qualifier separate from the authored name and stays inert", () => { + const { container } = render( + , + ); + expect( + screen.getByRole("img", { name: "Person Honey, public key ending c a j" }), + ).toHaveTextContent("@Honey npub…caj"); + expect(container.querySelector(".inline-chip-label")).toHaveTextContent( + "@Honey", + ); + expect(container.querySelector(".inline-chip-qualifier")).toHaveAttribute( + "data-reveal", + ); + expect(container.querySelector("button, a, [tabindex], [title]")).toBeNull(); +}); diff --git a/src/shared/design-system/ui/InlineChip.tsx b/src/shared/design-system/ui/InlineChip.tsx index 9b3724b55..4eafa0130 100644 --- a/src/shared/design-system/ui/InlineChip.tsx +++ b/src/shared/design-system/ui/InlineChip.tsx @@ -22,19 +22,28 @@ const KIND_ICON: Partial> = { }; /** Subscribes to face changes so a rename repaints without a document edit. */ -function useChipFace(address: ChipAddress): ChipFace { +function useChipFace(address: ChipAddress, face?: ChipFace): ChipFace { return useSyncExternalStore( - (listener) => chipFaces.subscribe(listener), - () => chipFaces.get(address), + (listener) => (face ? () => {} : chipFaces.subscribe(listener)), + () => face ?? chipFaces.get(address), + () => face ?? chipFaces.get(address), ); } export function InlineChip({ address, + face: suppliedFace, + qualifier, interactive = true, onActivate, }: { address: ChipAddress; + /** Scoped display data supplied by a host that already owns identity lookup. */ + face?: ChipFace; + /** Optional host-owned distinction; never part of the address or authored name. */ + qualifier?: + | { text: string; accessibleLabel: string; reveal?: boolean } + | undefined; /** * An inert rendering for places where even a preview would compete with the * surrounding interaction, such as a future editable document boundary. @@ -43,7 +52,7 @@ export function InlineChip({ /** An explicit owner-provided action; previews never imply a deep-open. */ onActivate?: (address: ChipAddress) => void; }) { - const face = useChipFace(address); + const face = useChipFace(address, suppliedFace); const Icon = KIND_ICON[address.kind]; const trigger = CHIP_KIND_TRIGGER[address.kind]; const kind = accessibleKind(address.kind); @@ -55,11 +64,19 @@ export function InlineChip({ {trigger} {face.label} + {qualifier ? ( + + {` ${qualifier.text}`} + + ) : null} ); const accessibleName = face.resolved - ? `${kind} ${face.label}` + ? `${kind} ${face.label}${qualifier ? `, ${qualifier.accessibleLabel}` : ""}` : `Unresolved ${kind.toLowerCase()}`; const state = face.loading ? "loading" diff --git a/src/shared/identity/public-key.test.ts b/src/shared/identity/public-key.test.ts new file mode 100644 index 000000000..eaa44852f --- /dev/null +++ b/src/shared/identity/public-key.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { formatPublicKey, publicKeyLabels } from "./public-key"; + +const first = "a".repeat(64); +const second = "b".repeat(64); + +describe("public-key display", () => { + it("uses canonical npub endings, not hex endings, and normalizes case", () => { + expect(formatPublicKey(first)).toBe("npub…caj"); + expect(formatPublicKey(first.toUpperCase())).toBe("npub…caj"); + expect(formatPublicKey(second, 4)).toBe("npub…04hu"); + }); + + it.each([ + "", + "not-a-key", + "a".repeat(63), + "g".repeat(64), + "nsec1secret", + "npub1broken", + ])("does not echo invalid input: %s", (input) => { + expect(formatPublicKey(input)).toBeUndefined(); + expect(publicKeyLabels([input]).size).toBe(0); + }); + + it("bounds requested lengths and never drops the public-key prefix", () => { + for (const length of [-1, 0, 2, NaN, Infinity, 3.5]) + expect(formatPublicKey(first, length)).toBe("npub…caj"); + expect(formatPublicKey(first, 999)).toMatch(/^npub…[a-z0-9]{58}$/); + }); + + it("deduplicates exact identities and uses stable labels independent of order", () => { + const labels = publicKeyLabels([first, second, first.toUpperCase()]); + expect([...labels]).toEqual([ + [first, "npub…caj"], + [second, "npub…4hu"], + ]); + expect(publicKeyLabels([second, first])).toEqual(labels); + }); + + it("extends all labels in a group when three ending characters collide", () => { + // Fixed canonical npubs ending 7c8a32 and 8mga32. + const a = "08c5".padStart(64, "0"); + const b = "4000".padStart(64, "0"); + expect(formatPublicKey(a)).toBe("npub…a32"); + expect(formatPublicKey(b)).toBe("npub…a32"); + expect([...publicKeyLabels([a, b, first]).values()]).toEqual([ + "npub…8a32", + "npub…ga32", + "npub…rcaj", + ]); + expect(publicKeyLabels([]).size).toBe(0); + }); +}); diff --git a/src/shared/identity/public-key.ts b/src/shared/identity/public-key.ts new file mode 100644 index 000000000..6f9148d92 --- /dev/null +++ b/src/shared/identity/public-key.ts @@ -0,0 +1,44 @@ +import { npubEncode } from "nostr-tools/nip19"; + +function canonicalPublicKey(pubkey: string): string | undefined { + return /^[0-9a-f]{64}$/i.test(pubkey) + ? npubEncode(pubkey.toLowerCase()) + : undefined; +} + +/** Display only: never use this abbreviation for copying, storage or routing. */ +export function formatPublicKey( + pubkey: string, + suffixLength = 3, +): string | undefined { + const npub = canonicalPublicKey(pubkey); + if (!npub) return undefined; + const length = Number.isInteger(suffixLength) + ? Math.max(3, Math.min(suffixLength, npub.length - 5)) + : 3; + return `npub…${npub.slice(-length)}`; +} + +/** Distinct public identities share the shortest unambiguous suffix, starting at three. */ +export function publicKeyLabels( + pubkeys: Iterable, +): ReadonlyMap { + const encoded = new Map(); + for (const pubkey of pubkeys) { + const npub = canonicalPublicKey(pubkey); + if (npub) encoded.set(pubkey.toLowerCase(), npub); + } + let length = 3; + const values = [...encoded.values()]; + while ( + length < 58 && + new Set(values.map((npub) => npub.slice(-length))).size < values.length + ) + length++; + return new Map( + [...encoded].map(([pubkey, npub]) => [ + pubkey, + `npub…${npub.slice(-length)}`, + ]), + ); +} diff --git a/tests/browser/completion-work.spec.mjs b/tests/browser/completion-work.spec.mjs index db1d73651..17af5b2a1 100644 --- a/tests/browser/completion-work.spec.mjs +++ b/tests/browser/completion-work.spec.mjs @@ -105,10 +105,9 @@ test("mention typing does not repeat cold reads or create phantom popup layout a expect(after.kinds).toEqual(warm.kinds); expect(report.libraryReads).toBe(warmLibraryReads); expect(sample).toEqual({ popups: 0, geometryReads: 0 }); + await expect(input.locator(".inline-chip")).toHaveCount(1); await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); + input.getByRole("img", { name: "Person Mary Jane" }), + ).toBeVisible(); await expect(input).not.toHaveAttribute("aria-controls"); }); diff --git a/tests/browser/composer-links.spec.mjs b/tests/browser/composer-links.spec.mjs index ebe50e2f8..c2df10b49 100644 --- a/tests/browser/composer-links.spec.mjs +++ b/tests/browser/composer-links.spec.mjs @@ -34,11 +34,11 @@ test("editable composer renders links and mentions while preserving source and n page.getByRole("region", { name: "Draft preview" }), ).toHaveCount(0); await expect(preview.locator('[data-link-kind="github"]')).toHaveCount(2); - await expect(preview.locator('[data-mention-kind="person"]')).toHaveText( - "Alex Chen", - ); - await expect(preview.locator('[data-mention-kind="agent"]')).toHaveText( - "Build Bot", + await expect( + preview.locator('.inline-chip[data-kind="person"]'), + ).toHaveText("@Alex Chen"); + await expect(preview.locator('.inline-chip[data-kind="agent"]')).toHaveText( + "@Build Bot", ); const delivered = page.locator('[data-message-id="link-row"]'); const deliveredLink = delivered @@ -147,8 +147,8 @@ test("editable composer renders links and mentions while preserving source and n expected.length, ); await expect(preview.locator("strong")).toHaveText("GitHub"); - await expect(preview.locator('[data-mention-kind="agent"]')).toHaveText( - "Build Bot", + await expect(preview.locator('.inline-chip[data-kind="agent"]')).toHaveText( + "@Build Bot", ); await input.press("Enter"); expect(await page.evaluate(() => window.linkComposerFixture.sent)).toEqual([ @@ -187,13 +187,13 @@ test("editable composer renders links and mentions while preserving source and n const pasted = "@Alex Chen [Drive](https://drive.google.com/file/example)"; await input.fill(pasted); - await expect(preview.locator("[data-mention-kind]")).toHaveCount(0); + await expect(preview.locator(".inline-chip")).toHaveCount(0); await expect(preview.locator('[data-link-kind="drive"]')).toHaveText( "Drive", ); await page.reload(); await expect(input).toHaveJSProperty("value", pasted); - await expect(preview.locator("[data-mention-kind]")).toHaveCount(0); + await expect(preview.locator(".inline-chip")).toHaveCount(0); // A rendered item at the end must have a real caret box after its label. const linkSource = "See https://github.com/block/buzz-app"; await input.fill(linkSource); @@ -466,15 +466,23 @@ test("editable composer renders links and mentions while preserving source and n .click(); await input.evaluate((el) => el.setSelectionRange(7, 17)); await input.press("Backspace"); - await expect(input.locator('[data-mention-kind="person"]')).toHaveCount(0); - await expect(input.locator('[data-mention-kind="agent"]')).toHaveCount(1); + await expect(input.locator('.inline-chip[data-kind="person"]')).toHaveCount( + 0, + ); + await expect(input.locator('.inline-chip[data-kind="agent"]')).toHaveCount( + 1, + ); await input.press("ControlOrMeta+z"); - await expect(input.locator('[data-mention-kind="person"]')).toHaveCount(1); + await expect(input.locator('.inline-chip[data-kind="person"]')).toHaveCount( + 1, + ); expect( await input.evaluate((el) => [el.selectionStart, el.selectionEnd]), ).toEqual([7, 17]); await input.press("ControlOrMeta+Shift+z"); - await expect(input.locator('[data-mention-kind="person"]')).toHaveCount(0); + await expect(input.locator('.inline-chip[data-kind="person"]')).toHaveCount( + 0, + ); await input.press("Enter"); expect( (await page.evaluate(() => window.linkComposerFixture.sent)).at(-1), diff --git a/tests/browser/conversation.spec.mjs b/tests/browser/conversation.spec.mjs index ccaca2a21..7c97873e6 100644 --- a/tests/browser/conversation.spec.mjs +++ b/tests/browser/conversation.spec.mjs @@ -218,18 +218,9 @@ test("independent packed author consumer and native-installed contribution survi .getByRole("button", { name: "Insert mixed", exact: true }) .click(); await expect(draft).toHaveJSProperty("value", "Hi @Member and @Member "); - // Two authored spans refer to one notification identity and removal control. + // Two authored spans preserve one exact notification identity. const member = await page.evaluate(() => window.conversationFixture.member); - const mixedRecipients = page.getByRole("region", { - name: "Notification recipients", - }); - await expect(mixedRecipients.getByRole("button")).toHaveCount(1); - await expect( - mixedRecipients.getByRole("button", { - name: `Remove mention Member ${member}`, - exact: true, - }), - ).toBeVisible(); + await expect(draft.locator(".inline-chip")).toHaveCount(2); await expect .poll(() => page.evaluate(() => @@ -282,10 +273,8 @@ test("independent packed author consumer and native-installed contribution survi await page .getByRole("button", { name: `Member ${member}`, exact: true }) .click(); - const recipients = page.getByRole("region", { - name: "Notification recipients", - }); - await expect(recipients.getByRole("button")).toHaveCount(1); + const recipients = draft.locator(".inline-chip"); + await expect(recipients).toHaveCount(1); const withMention = await draft.evaluate((element) => element.value); const textarea = await draft.elementHandle(); await page.evaluate(() => @@ -295,11 +284,19 @@ test("independent packed author consumer and native-installed contribution survi page.getByRole("button", { name: "Mention a member", exact: true }), ).toHaveCount(0); await expect(draft).toHaveJSProperty("value", withMention); - await expect(recipients.getByRole("button")).toHaveCount(1); + await expect(recipients).toHaveCount(1); expect(await textarea.evaluate((el) => el.isConnected)).toBe(true); - await recipients.getByRole("button").click(); + await draft.focus(); + await draft.evaluate((element) => { + const start = element.value.indexOf("@Member"); + element.setSelectionRange(start, start + "@Member".length); + }); + await draft.press("Backspace"); await expect(recipients).toHaveCount(0); - await expect(draft).toHaveJSProperty("value", withMention); + await expect(draft).toHaveJSProperty( + "value", + withMention.replace("@Member", ""), + ); await page.evaluate(() => window.conversationFixture.change("enable", "buzz.mentions"), ); diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index c9c90748b..b76505fea 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -1,3 +1,4 @@ +import { npubEncode } from "nostr-tools/nip19"; import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; @@ -50,11 +51,94 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta buttons.map((button) => button.getAttribute("aria-label")), ); await expect.poll(order).toEqual(["Mention a member", "Insert emoji"]); + const input = page.getByRole("textbox", { name: "Message #General" }); await choose(keys.first); + await expect(input.locator(".inline-chip")).toHaveText("@Honey"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.evaluate(() => { + window.qualifierReveals = []; + document.addEventListener("animationstart", (event) => { + if (event.animationName !== "inline-chip-qualifier-reveal") return; + window.qualifierReveals.push(event.target); + for (const animation of event.target.getAnimations()) { + animation.pause(); + animation.currentTime = 0; + } + }); + }); await choose(keys.second); + const labels = [keys.first, keys.second].map( + (key) => `@Honey · npub…${npubEncode(key).slice(-3)}`, + ); + await expect(input.locator(".inline-chip")).toHaveText(labels); + await expect + .poll(() => page.evaluate(() => window.qualifierReveals.length)) + .toBe(1); + const widths = await input + .locator(".inline-chip-qualifier") + .first() + .evaluate((element) => { + const animation = element.getAnimations()[0]; + const start = element.getBoundingClientRect().width; + const duration = animation.effect.getTiming().duration; + animation.currentTime = duration / 2; + const middle = element.getBoundingClientRect().width; + animation.finish(); + return { start, middle, end: element.getBoundingClientRect().width }; + }); + expect(widths.start).toBeLessThan(widths.middle); + expect(widths.middle).toBeLessThan(widths.end); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(input.locator(".inline-chip-qualifier").first()).toHaveCSS( + "animation-name", + "none", + ); + await expect( + input.locator(".inline-chip-qualifier").last(), + ).not.toHaveAttribute("data-reveal"); + // Copy serializes authored source, not the visible namesake qualifiers. + await input.focus(); + await input.press("ControlOrMeta+a"); + await expect(input.locator("[data-editor-selected]")).toHaveCount(2); + const copied = await input.evaluate((element) => { + const clipboardData = new DataTransfer(); + element.dispatchEvent( + new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + return clipboardData.getData("text/plain"); + }); + expect(copied).toBe("@Honey @Honey "); + await input.press("ArrowRight"); + // Typing can rebuild editor portals; it must not replay the reveal. + await input.press("x"); + await expect(input).toHaveJSProperty("value", "@Honey @Honey x"); + await expect(input.locator("[data-reveal]")).toHaveCount(0); + await input.press("Backspace"); + await page.emulateMedia({ reducedMotion: "reduce" }); + // The first chip's visual expansion must not change native source offsets. + await input.focus(); + await input.evaluate((element) => element.setSelectionRange(7, 13)); + await input.press("Backspace"); + await expect(input).toHaveJSProperty("value", "@Honey "); + await expect(input.locator(".inline-chip")).toHaveText("@Honey"); + await input.press(process.platform === "darwin" ? "Meta+z" : "Control+z"); + await expect(input).toHaveJSProperty("value", "@Honey @Honey "); + await expect(input.locator(".inline-chip")).toHaveText(labels); + await expect(input.locator("[data-reveal]")).toHaveCount(0); + await expect(input.locator(".inline-chip-qualifier").first()).toHaveCSS( + "animation-name", + "none", + ); + // Undo restores the former selected range. Continue the original typing journey at its end. + await input.evaluate((element) => + element.setSelectionRange(element.value.length, element.value.length), + ); // The merged toolbar must preserve exact recipients while the new picker // inserts Unicode and follows the host mode without recreating the draft. - const input = page.getByRole("textbox", { name: "Message #General" }); await page.evaluate(() => { document.documentElement.dataset.colorMode = "dark"; }); @@ -69,25 +153,51 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await search.fill("grinning"); await page.getByRole("button", { name: "😀", exact: true }).click(); await expect(input).toHaveJSProperty("value", "@Honey @Honey 😀"); + await expect(page.getByRole("textbox").locator(".inline-chip")).toHaveCount( + 2, + ); + const chip = input.locator(".inline-chip").first(); await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(2); - const chip = page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button") - .first(); - const removal = chip.locator("[data-avatar-shape] > span").last(); - await expect(removal).toHaveCSS("opacity", "0"); + page.getByRole("region", { name: "Notification recipients" }), + ).toHaveCount(0); + const chipRoles = await chip.evaluate((element) => { + const probe = document.createElement("span"); + probe.style.backgroundColor = "var(--affordance-accent)"; + probe.style.color = "var(--text-standard)"; + element.append(probe); + const style = getComputedStyle(probe); + const roles = { background: style.backgroundColor, text: style.color }; + probe.remove(); + return roles; + }); + await expect(chip).toHaveCSS("background-color", chipRoles.background); + await expect(chip).toHaveCSS("color", chipRoles.text); + // Browser-only: shared chip geometry across themes/widths, without a + // nested focus target or hover preview competing with native editing. + for (const mode of ["light", "dark"]) { + await page.evaluate((mode) => { + document.documentElement.dataset.colorMode = mode; + }, mode); + for (const width of [360, 768, 1440]) { + await page.setViewportSize({ width, height: 950 }); + const bounds = await input.boundingBox(); + for (const item of await input.locator(".inline-chip").all()) { + const box = await item.boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(bounds.x); + expect(box.x + box.width).toBeLessThanOrEqual( + bounds.x + bounds.width, + ); + } + await expect(input).toHaveJSProperty("value", "@Honey @Honey 😀"); + } + } await chip.hover(); - await expect(removal).toHaveCSS("opacity", "1"); - expect( - await removal.evaluate((el) => { - const style = getComputedStyle(el); - return style.color !== style.backgroundColor; - }), - ).toBe(true); + await chip.click(); + await expect(input.locator("button, a, [tabindex], [title]")).toHaveCount( + 0, + ); + await expect(page.locator(".buzz-preview-card")).toHaveCount(0); + await input.press("Escape"); await page.screenshot({ path: test.info().outputPath("mention-recipients.png"), }); @@ -122,11 +232,9 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect( page.getByRole("button", { name: "Mention a member", exact: true }), ).toHaveCount(0); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); + await expect(page.getByRole("textbox").locator(".inline-chip")).toHaveCount( + 1, + ); await page .getByRole("button", { name: "Send message", exact: true }) .click(); @@ -176,11 +284,9 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect( page.getByRole("textbox", { name: "Reply to thread" }), ).toHaveJSProperty("value", "@Honey "); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); + await expect(page.getByRole("textbox").locator(".inline-chip")).toHaveCount( + 1, + ); await page .getByRole("button", { name: "Toggle disabled", exact: true }) .click(); diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index 32fb7ba4e..03b6ef1f3 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -450,11 +450,9 @@ test("media review completions stay visible and preserve modal keyboard ownershi await mention.click(); await expect(input).toHaveJSProperty("value", "@Fixture Reader "); await expect(input).toBeFocused(); - await expect( - dialog - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button", { name: /^Remove mention Fixture Reader / }), - ).toBeVisible(); + await expect(input.locator(".inline-chip")).toContainText( + "Fixture Reader", + ); for (const key of ["Enter", "Tab"]) { await input.fill(":smile"); const emoji = page diff --git a/tests/browser/product-ui.spec.mjs b/tests/browser/product-ui.spec.mjs index 2f50fdbf4..db6d9c045 100644 --- a/tests/browser/product-ui.spec.mjs +++ b/tests/browser/product-ui.spec.mjs @@ -1,3 +1,4 @@ +import { npubEncode } from "nostr-tools/nip19"; import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; @@ -98,3 +99,37 @@ test("catalogue preserves disabled, read-only and failed-send recovery examples" "Retry this message", ); }); + +// Browser-only: choose real picker entries and observe inline chip relabeling. +test("playground distinguishes same-name people and agents in the real composer", async ({ + page, +}) => { + await page.goto(url); + const playground = page.getByRole("region", { name: "Composer playground" }); + const chips = playground.getByRole("textbox").locator(".inline-chip"); + const labels = []; + for (const [name, keys] of [ + ["Alice", ["a".repeat(64), "c".repeat(64)]], + ["Honey", ["b".repeat(64), "d".repeat(64)]], + ]) { + for (const [index, key] of keys.entries()) { + await playground + .getByRole("button", { name: "Mention a member", exact: true }) + .click(); + await page + .getByRole("region", { name: "Mention a member or agent" }) + .getByRole("button", { name: `${name} ${key}`, exact: true }) + .click(); + if (index === 0) { + await expect(chips).toHaveText([...labels, `@${name}`]); + } else { + labels.push( + ...keys.map( + (value) => `@${name} · npub…${npubEncode(value).slice(-3)}`, + ), + ); + await expect(chips).toHaveText(labels); + } + } + } +}); diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index 0560ac89e..cea459d9a 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -175,11 +175,7 @@ test("typeahead replaces only the query and publishes selected namesake identity await option.click(); await expect(input).toBeFocused(); await expect(input).toHaveJSProperty("value", "Before @Honey after"); - await expect( - page - .getByRole("region", { name: "Notification recipients" }) - .getByRole("button"), - ).toHaveCount(1); + await expect(input.locator(".inline-chip")).toHaveCount(1); await input.press("Enter"); await expect .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) @@ -948,9 +944,7 @@ test("a later emoji trigger wins after a mention without discarding recipient in await expect(page.getByRole("option").first()).toContainText(":smile:"); await input.press("Tab"); await expect(input).toHaveJSProperty("value", "@Honey 😄"); - await expect( - page.getByRole("region", { name: "Notification recipients" }), - ).toHaveCount(0); + await expect(input.locator(".inline-chip")).toHaveCount(0); }); test("portal bounds hold when the focused composer moves outside the viewport", async ({ From dfc5a1f3f3a12b8249acfed6a0bfe95c6eb267a3 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:30:30 -0700 Subject: [PATCH 2/2] Keep selected mention identities visible inside Markdown literals Give explicit recipients precedence over inferred decorations without changing source or notification semantics. Cover literal and depth fallback disclosure, restored drafts, copy, and exact channel/reply publication. Co-authored-by: Carl Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../messages/MessageComposer.test.tsx | 51 +++++++++++ src/features/messages/RichComposerInput.tsx | 13 +-- tests/browser/mentions.spec.mjs | 90 +++++++++++++++++++ 3 files changed, 148 insertions(+), 6 deletions(-) diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 291ebec61..93fbd85f8 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -539,6 +539,57 @@ it.each([undefined, "root"])( }, ); +// Explicit notification intent must remain visible even where Markdown previews are suppressed. +it.each([ + ["inline code", "`", " `"], + ["fenced code", "```\n", "\n```"], + ["indented code", " ", ""], + ["image", "![", "](https://example.test/image.png)"], + [ + "image reference", + "![", + "][image]\n\n[image]: https://example.test/image.png", + ], + ["definition", '[image]: https://example.test/image.png "', '"'], + ["HTML", ""], + ["link label", "[", "](https://example.test)"], + ["deep Markdown", "> ".repeat(101), ""], +])( + "discloses selected namesakes in %s before and after restoring a draft", + (_kind, prefix, suffix) => { + let h = mount(); + h.fill(`${prefix}@Honey ${suffix}`); + expect(h.input().querySelector(".inline-chip")).toBeNull(); + h.submit(); + expect(h.messages.send.mock.calls.at(-1)?.at(-1)).toEqual([]); + h.fill(`${prefix}${suffix}`); + h.input().setSelectionRange(prefix.length, prefix.length); + act(() => { + h.commands().insertMention(first); + h.commands().insertMention(second); + }); + const text = `${prefix}@Honey @Honey ${suffix}`; + const labels = [ + "Person Honey, public key ending c a j", + "Person Honey, public key ending 4 h u", + ]; + const check = () => { + expect(h.input()).toHaveValue(text); + for (const name of labels) + expect(within(h.input()).getByRole("img", { name })).toBeVisible(); + }; + check(); + h.unmount(); + h = mount(); + check(); + h.submit(); + expect(h.messages.send).toHaveBeenCalledWith("channel", text, [ + first.pubkey, + second.pubkey, + ]); + }, +); + it.each([undefined, "root"])( "keeps an untouched mention when smart punctuation replaces text behind the caret in %s", (root) => { diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index df1235841..7fb6f1f8d 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -123,18 +123,19 @@ export function RichComposerInput({ editAsText = false, ) => { if ( - !tooDeep && - ![...literals, ...ranges].some( - (range) => start < range.end && end > range.start, - ) + // Explicit recipients disclose notification intent even inside Markdown literals. + (mention || + (!tooDeep && + !literals.some((range) => start < range.end && end > range.start))) && + !ranges.some((range) => start < range.end && end > range.start) ) ranges.push({ start, end, editAsText, ...(mention ? { mention } : {}) }); }; + for (const recipient of draft.recipients) + add(recipient.start, recipient.end, recipient.pubkey); messageLinkParts(draft.text, undefined, (start, end) => add(start, end, undefined, true), ); - for (const recipient of draft.recipients) - add(recipient.start, recipient.end, recipient.pubkey); for (const reference of messageReferences( draft.text, [], diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index b76505fea..8f7a64db1 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -299,3 +299,93 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await server.close(); } }); + +test("selected mentions inside code remain visible through draft restore and channel/reply publication", async ({ + page, +}) => { + // Browser-only boundary: real picker insertion and source selection in a code literal. + await page.addInitScript(() => { + localStorage.setItem("buzz-remember-mentioned-agents.v1", "off"); + }); + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0 }, + }); + try { + await server.listen(); + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/mentions.html`, + ); + const keys = await page.evaluate(() => [ + window.mentionFixture.first, + window.mentionFixture.second, + ]); + const input = page.getByRole("textbox"); + const labels = keys.map( + (key) => `@Honey · npub…${npubEncode(key).slice(-3)}`, + ); + for (const reply of [false, true]) { + await input.fill("` `"); + await input.evaluate((element) => element.setSelectionRange(1, 1)); + for (const key of keys) { + await page + .getByRole("button", { name: "Mention a member", exact: true }) + .click(); + await page + .getByRole("region", { name: "Mention a member or agent" }) + .getByRole("button", { name: `Honey ${key}`, exact: true }) + .click(); + } + const source = "`@Honey @Honey `"; + await expect(input).toHaveJSProperty("value", source); + await expect(input.locator(".inline-chip")).toHaveText(labels); + // Retargeting unmounts the destination's composer and restores its saved draft. + await page.getByRole("button", { name: "Toggle thread" }).click(); + await expect(input).toHaveJSProperty("value", ""); + await page.getByRole("button", { name: "Toggle thread" }).click(); + await expect(input).toHaveJSProperty("value", source); + await expect(input.locator(".inline-chip")).toHaveText(labels); + await input.focus(); + await input.press("ControlOrMeta+a"); + const copied = await input.evaluate((element) => { + const clipboardData = new DataTransfer(); + element.dispatchEvent( + new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + return clipboardData.getData("text/plain"); + }); + expect(copied).toBe(source); + await page + .getByRole("button", { name: "Send message", exact: true }) + .click(); + await expect + .poll(() => + page.evaluate(() => window.mentionFixture.publications.length), + ) + .toBe(reply ? 2 : 1); + const sent = await page.evaluate(() => + window.mentionFixture.publications.at(-1), + ); + expect(sent.content).toBe(source); + expect(sent.tags.filter(([tag]) => tag === "p")).toEqual( + keys.map((key) => ["p", key]), + ); + expect(sent.tags.filter(([tag]) => tag === "h")).toEqual([["h", "c"]]); + expect(sent.tags.filter(([tag]) => tag === "e")).toEqual( + reply ? [["e", "a".repeat(64), "", "reply"]] : [], + ); + if (!reply) + await page.getByRole("button", { name: "Toggle thread" }).click(); + } + } finally { + await server.close(); + } +});