Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ test("markdown parsing stops message links before emphasis delimiters", () => {
assert.deepEqual(token.meta, { channelName: "general", href: HREF });
});

function renderedChipLabel(rendered) {
return `${rendered[2][2]}${rendered[3]}`;
}

test("composer node uses the sent-message chip presentation", () => {
const node = {
attrs: { channelName: "general", href: HREF },
Expand All @@ -209,7 +213,44 @@ test("composer node uses the sent-message chip presentation", () => {
assert.equal(rendered[1]["data-buzz-link"], "");
// Channel label only — no event hash, so the chip does not change width when
// the draft is sent and the rendered chip resolves its metadata.
assert.equal(rendered[2], "general");
assert.match(rendered[1].class, /wrapping-inline-chip/);
assert.match(rendered[2][1].class, /inline-chip-leading-fragment/);
assert.equal(renderedChipLabel(rendered), "general");
});

test("composer node truncates and preserves grapheme-safe leading fragments", () => {
const render = ComposerMessageLinkNode.configure({
resolveChannelName: () => undefined,
}).config.renderHTML;
assert.ok(render);

const longName = `relay-${"observability".repeat(5)}`;
const longRendered = render.call(
{ options: { resolveChannelName: () => undefined } },
{
node: { attrs: { channelName: longName, href: HREF } },
HTMLAttributes: {},
},
);
assert.equal(renderedChipLabel(longRendered), `${longName.slice(0, 47)}…`);

for (const [label, expectedLeading] of [
["🇺🇸channel", "🇺🇸chan"],
["e\u0301quipe", "e\u0301quip"],
["relaytoolsobservabilityconsole-main", "relay"],
[" leading-space", ""],
]) {
const rendered = render.call(
{ options: { resolveChannelName: () => undefined } },
{
node: { attrs: { channelName: label, href: HREF } },
HTMLAttributes: {},
},
);
assert.equal(rendered[2][2], expectedLeading);
assert.match(rendered[2][1].class, /inline-chip-with-icon/);
assert.equal(renderedChipLabel(rendered), label);
}
});

test("composer node renders channel and entity chip presentations", () => {
Expand All @@ -227,24 +268,24 @@ test("composer node renders channel and entity chip presentations", () => {
const channel = render(CHANNEL_HREF);
assert.equal(channel[1]["data-channel-deep-link"], "");
assert.match(channel[1].class, /inline-chip-icon-channel/);
assert.equal(channel[2], "general");
assert.equal(renderedChipLabel(channel), "general");

const repo = render(REPO_HREF);
assert.equal(repo[1]["data-buzz-link-kind"], "repo");
assert.match(repo[1].class, /inline-chip-icon-repo/);
assert.equal(repo[2], "buzz-world");
assert.equal(renderedChipLabel(repo), "buzz-world");

const issue = render(ISSUE_HREF);
assert.equal(issue[1]["data-buzz-link-kind"], "issue");
assert.match(issue[1].class, /inline-chip-icon-issue/);
// Repository name only — the rendered chip never widens into the issue
// title, so the composer must not widen into the event hash either.
assert.equal(issue[2], "buzz-world");
assert.equal(renderedChipLabel(issue), "buzz-world");

const pullRequest = render(PR_HREF);
assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr");
assert.match(pullRequest[1].class, /inline-chip-icon-pr/);
assert.equal(pullRequest[2], "buzz-world");
assert.equal(renderedChipLabel(pullRequest), "buzz-world");
});

test("markdown rendering stores identity in attributes, not visible id text", () => {
Expand Down
45 changes: 43 additions & 2 deletions desktop/src/features/messages/lib/composerMessageLinkNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import {
} from "@/shared/lib/entityLink";
import {
inlineChipIconClasses,
inlineChipLeadingEnd,
type InlineChipIconKind,
MENTION_CHIP_BASE_CLASSES,
truncateInlineChipLabel,
WRAPPING_INLINE_CHIP_CLASSES,
} from "@/shared/ui/mentionChip";
import { buildChannelLink, parseChannelLink } from "./channelLink";
import { getMessageLinkLabel } from "./messageLinkLabel";
Expand Down Expand Up @@ -285,6 +288,38 @@ function composerLinkPresentation(
};
}

function wrappingComposerChipContent(
label: string,
icon: InlineChipIconKind,
): { leading: [string, Record<string, string>, string]; remainder: string } {
const leadingEnd = inlineChipLeadingEnd(label);
if (!leadingEnd) {
return {
leading: [
"span",
{
"aria-hidden": "true",
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
},
"",
],
remainder: label,
};
}

return {
leading: [
"span",
{
"aria-hidden": "true",
class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`,
},
label.slice(0, leadingEnd),
],
remainder: label.slice(leadingEnd),
};
}

export const ComposerMessageLinkNode =
Node.create<ComposerMessageLinkNodeOptions>({
name: COMPOSER_MESSAGE_LINK_NODE_NAME,
Expand Down Expand Up @@ -328,19 +363,25 @@ export const ComposerMessageLinkNode =
String(node.attrs.channelName ?? ""),
this.options.resolveChannelName,
);
const visibleLabel = truncateInlineChipLabel(presentation.label);
const content = wrappingComposerChipContent(
visibleLabel,
presentation.icon,
);
return [
"span",
mergeAttributes(HTMLAttributes, {
"aria-label": presentation.ariaLabel,
class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
class: `${MENTION_CHIP_BASE_CLASSES} ${WRAPPING_INLINE_CHIP_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
"data-buzz-link": "",
"data-channel-name": presentation.channelName,
"data-composer-buzz-link": "",
"data-href": href,
...presentation.dataAttributes,
title: presentation.ariaLabel,
}),
presentation.label,
content.leading,
content.remainder,
];
},

Expand Down
10 changes: 5 additions & 5 deletions desktop/src/shared/styles/globals/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,11 @@
Body copy is text-sm; inline code labels sit one step down (text-xs).
All chip variants share one box height so mono labels stay balanced.

Rendered-message chips use inline-flex. The same decoration in the
composer pulls the caret into the chip and swallows the trailing
space after an @mention, so typing "hello" after @quinn becomes
"@quinnhello". Keep composer chips inline; leave ::before icons in
place. */
Plain-text mention decorations stay inline because inline-flex pulls the
caret into their decorated range and swallows the trailing space after an
@mention. Composer Buzz links are atom nodes, but their labels may still
fragment between characters; only the icon and leading label fragment stay
together. */
.rich-text-composer .tiptap .mention-chip {
display: inline;
min-height: 0;
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/shared/styles/globals/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@
white-space: nowrap;
}

.message-markdown
.inline-chip-leading-fragment.inline-chip-with-icon:empty::after {
content: "\200b";
}

.message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before {
display: block;
left: 0;
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/shared/ui/markdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1121,7 +1121,7 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
assert.equal((html.match(/data-channel-deep-link=""/g) ?? []).length, 1);
assert.match(html, /inline-chip-icon-channel/);
assert.match(html, /wrapping-inline-chip/);
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
assert.match(html, /inline-chip-icon-pr/);
assert.match(html, /inline-chip-icon-issue/);
assert.match(html, /inline-chip-icon-repo/);
Expand Down Expand Up @@ -1299,7 +1299,7 @@ test("bare Buzz permalinks shorten unavailable channel identifiers", () => {
);

assert.equal(
(html.match(/inline-chip-leading-fragment[^>]*>5<\/span>80ca78b/g) ?? [])
(html.match(/inline-chip-leading-fragment[^>]*>580ca<\/span>78b/g) ?? [])
.length,
2,
);
Expand Down Expand Up @@ -1332,7 +1332,7 @@ test("channel references replace the authored hash with the channel icon", () =>

assert.match(html, /inline-chip-icon-channel/);
assert.match(html, /wrapping-inline-chip/);
assert.match(html, /inline-chip-leading-fragment[^>]*>e</);
assert.match(html, /inline-chip-leading-fragment[^>]*>engin</);
assert.match(html.replace(/<[^>]+>/g, ""), /engineering/);
assert.doesNotMatch(html, />#engineering</);
});
Expand Down
22 changes: 16 additions & 6 deletions desktop/src/shared/ui/markdown/BuzzLinkChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn";
import { InlineChip } from "@/shared/ui/InlineChip";
import {
inlineChipIconClasses,
inlineChipLeadingEnd,
type InlineChipIconKind,
truncateInlineChipLabel,
WRAPPING_INLINE_CHIP_CLASSES,
Expand Down Expand Up @@ -72,12 +73,21 @@ function wrappingChipContent(
): React.ReactNode {
if (typeof children !== "string" || children.length === 0) return children;

const separatorIndex = children.search(/[-\s]/u);
const leadingEnd =
separatorIndex < 0
? Array.from(children)[0]?.length
: separatorIndex + (children[separatorIndex] === "-" ? 1 : 0);
if (!leadingEnd) return children;
const leadingEnd = inlineChipLeadingEnd(children);
if (!leadingEnd) {
return (
<>
<span
aria-hidden="true"
className={cn(
"inline-chip-leading-fragment",
inlineChipIconClasses(icon),
)}
/>
{children}
</>
);
}

return (
<>
Expand Down
41 changes: 36 additions & 5 deletions desktop/src/shared/ui/mentionChip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,45 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";

const INLINE_CHIP_LABEL_MAX_CHARACTERS = 48;
const INLINE_CHIP_LEADING_MAX_GRAPHEMES = 5;

const inlineChipGraphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;

function inlineChipGraphemes(label: string): string[] {
return inlineChipGraphemeSegmenter
? Array.from(
inlineChipGraphemeSegmenter.segment(label),
({ segment }) => segment,
)
: Array.from(label);
}

/** Caps a fragmentable chip label without changing its underlying metadata. */
export function truncateInlineChipLabel(label: string): string {
const characters = Array.from(label);
if (characters.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
return `${characters
.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1)
.join("")}…`;
const graphemes = inlineChipGraphemes(label);
if (graphemes.length <= INLINE_CHIP_LABEL_MAX_CHARACTERS) return label;
return `${graphemes.slice(0, INLINE_CHIP_LABEL_MAX_CHARACTERS - 1).join("")}…`;
}

/** Returns a bounded boundary after the icon-bearing prefix. */
export function inlineChipLeadingEnd(label: string): number {
const graphemes = inlineChipGraphemes(label);
if (graphemes.length === 0) return 0;

let offset = 0;
for (const grapheme of graphemes.slice(
0,
INLINE_CHIP_LEADING_MAX_GRAPHEMES,
)) {
const nextOffset = offset + grapheme.length;
if (/\s/u.test(grapheme)) return offset;
offset = nextOffset;
if (grapheme === "-") return offset;
}
return offset;
}

/** Allows a long chip to fragment into separately decorated line boxes. */
Expand Down
Loading
Loading