Files
buzz/desktop/src/shared/ui/markdown/utils.ts
T
5acb930821 feat(desktop-messages): render compact Buzz permalink chips (#5638)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now open reliably and display recognizable context in the
desktop app.
**Problem:** Buzz links could appear as raw or ambiguous URLs, and
navigation links received during startup or community transitions could
be dropped before the UI was ready. Repository and issue shares in
particular required hover context to understand at a glance.
**Solution:** Queue desktop channel/message navigation until the UI is
ready, then render bare Buzz permalinks as icon-prefixed chips with
concise entity context while preserving user-authored Markdown labels as
ordinary links.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/deep_link.rs**
Adds validated channel-link parsing and a deduplicated, acknowledged
queue so navigation survives frontend startup.

**desktop/src-tauri/src/lib.rs**
Registers the pending-navigation state and commands with the desktop
application.

**desktop/src/features/communities/useCommunityInit.ts**
Resets queued navigation safely across community boundaries without
leaking stale destinations.

**desktop/src/features/messages/lib/channelLink.test.mjs**
Covers valid, malformed, and canonical channel permalink forms.

**desktop/src/features/messages/lib/channelLink.ts**
Defines strict parsing and detection for `buzz://channel/<uuid>` links.

**desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs**
Extends composer-node coverage for normalized Buzz link content.

**desktop/src/features/messages/lib/composerMessageLinkNode.ts**
Keeps composer link-node handling aligned with the expanded Buzz link
surface.

**desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs**
Verifies bare channel URLs become renderable deep-link nodes without
touching code.

**desktop/src/features/messages/lib/remarkChannelDeepLinks.ts**
Transforms eligible bare channel links into dedicated Markdown nodes.

**desktop/src/features/messages/lib/remarkEntityLinks.test.mjs**
Covers bare repository, pull-request, and issue detection and code-span
exclusions.

**desktop/src/features/messages/lib/remarkEntityLinks.ts**
Adds dedicated Markdown nodes for bare Buzz project entities.

**desktop/src/shared/deep-link.test.mjs**
Exercises queued navigation, acknowledgement, serialization, and
community-switch behavior.

**desktop/src/shared/deep-link.ts**
Serializes pending deep-link drains and acknowledges destinations only
after successful navigation.

**desktop/src/shared/styles/globals/markdown.css**
Aligns permalink icon geometry and spacing with agent mention chips.

**desktop/src/shared/ui/markdown.test.mjs**
Adds integration coverage for every permalink chip, authored labels,
fallbacks, icons, and static rendering.

**desktop/src/shared/ui/markdown.tsx**
Routes channel and entity nodes through the shared presentation path
while preserving authored link text.

**desktop/src/shared/ui/markdown/BuzzLinkChip.tsx**
Introduces the shared interactive/static permalink chip and
authored-label inline-link components.

**desktop/src/shared/ui/markdown/ChannelDeepLink.tsx**
Renders channel shares and references with Hash icons, names, and
shortened-ID fallbacks.

**desktop/src/shared/ui/markdown/MessageLinkPill.tsx**
Renders ordinary message shares with message icons and channel/message
context while retaining sent-from-thread behavior.

**desktop/src/shared/ui/markdown/entityLinks.tsx**
Maps repositories, pull requests, and issues to Projects-aligned icons
and contextual labels.

**desktop/src/shared/ui/markdown/nodeCache.ts**
Includes entity-link rendering in cached Markdown node handling.

**desktop/src/shared/ui/markdown/utils.ts**
Allows validated channel links through the Buzz URL transform.

**desktop/src/shared/useMessageDeepLinks.ts**
Drains queued navigation links safely and clears them during teardown.

**desktop/src/testing/e2eBridge.ts**
Extends the mock bridge with pending-navigation command behavior.

**desktop/tests/e2e/community-rail.spec.ts**
Verifies queued links do not cross community boundaries.

**desktop/tests/e2e/navigation.spec.ts**
Covers channel/message deep-link navigation during startup and active
sessions.

**desktop/tests/helpers/bridge.ts**
Adds reusable deep-link mock state and acknowledgement helpers.


</details>

## Reproduction steps
1. Run the desktop app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
in warm and cold-start states.

## Screenshots / demos
Houston dark theme with custom purple accent (`#a855f7`), captured from
rebased visual implementation `ad411cc06`; current head `0aafa144f` only
adjusts E2E expectations for the visible mention-label behavior shown
here.

**Composer — channel, message, repository, pull request, and issue
pills**

![Composer with all Buzz permalink pill types in Houston dark theme and
purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/composer-all-permalink-pills-dark-purple.png)

**Message list — channel, message, repository, pull request, and issue
pills**

![Message list with all Buzz permalink pill types in Houston dark theme
and purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/message-list-all-pill-types-dark-purple.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-14 10:18:18 -07:00

214 lines
7.3 KiB
TypeScript

import * as React from "react";
import { defaultUrlTransform } from "react-markdown";
import { isChannelLink } from "@/features/messages/lib/channelLink";
import { isMessageLink } from "@/features/messages/lib/messageLink";
import { parseEntityLink } from "@/shared/lib/entityLink";
export function useStableArray<T>(arr: T[]): T[] {
const ref = React.useRef(arr);
if (
arr.length !== ref.current.length ||
arr.some((item, i) => item !== ref.current[i])
) {
ref.current = arr;
}
return ref.current;
}
export function aspectRatioFromDim(dim?: string): number | undefined {
if (!dim) return undefined;
const match = dim.match(/^(\d+)x(\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) {
return undefined;
}
return width / height;
}
/**
* Parse a NIP-92 `dim` value ("WxH") into intrinsic pixel dimensions. Used to
* stamp explicit `width`/`height` attributes on inline images so the browser
* reserves aspect-ratio-correct layout space *before* the image decodes. This
* is what keeps the timeline from jumping when a tall image loads late — the
* row's height is known at first paint instead of growing from ~0 on load.
*/
export function dimensionsFromDim(
dim?: string,
): { width: number; height: number } | undefined {
if (!dim) return undefined;
const match = dim.match(/^(\d+)x(\d+)$/i);
if (!match) return undefined;
const width = Number(match[1]);
const height = Number(match[2]);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
// Natural pixel sizes of images that arrived without a NIP-92 `dim` tag,
// keyed by resolved URL and learned from the first decode. A re-render or
// scrollback of the same URL then reserves correct space immediately instead
// of growing the row from ~0 on decode.
const decodedImageDimensions = new Map<
string,
{ height: number; width: number }
>();
export function rememberDecodedImageDimensions(
url: string | undefined,
width: number,
height: number,
): void {
if (!url || !Number.isFinite(width) || !Number.isFinite(height)) return;
if (width <= 0 || height <= 0) return;
decodedImageDimensions.set(url, { height, width });
}
export function getDecodedImageDimensions(
url: string | undefined,
): { width: number; height: number } | undefined {
return url ? decodedImageDimensions.get(url) : undefined;
}
// Fixed box for a dim-less image whose real size isn't known yet — reserves a
// stable height (matching the inline max-h-64 cap) so a late decode letterboxes
// inside it instead of growing the row. Width is the inline display cap.
const DEFAULT_IMAGE_RESERVE = { height: 256, width: 384 } as const;
/**
* Decide the layout box to reserve for an inline image before it decodes.
*
* Prefer the NIP-92 `dim`, else the size learned from a prior decode of this
* URL — both let the row settle at the image's true height with no shift. A
* first-ever dim-less image has no known size, so it reserves a fixed-height
* box (caller letterboxes via object-contain) that does NOT change when the
* bytes arrive; the decoded size is cached for the next view.
*/
function resolveImageReserveBox(
dim: string | undefined,
resolvedSrc: string | undefined,
): {
intrinsicDimensions: { width: number; height: number };
useFixedReserveBox: boolean;
} {
const known =
dimensionsFromDim(dim) ?? getDecodedImageDimensions(resolvedSrc);
return {
intrinsicDimensions: known ?? DEFAULT_IMAGE_RESERVE,
useFixedReserveBox: !known,
};
}
/**
* Resolve the image reserve box once per mount. `resolveImageReserveBox` is
* pure in `(dim, resolvedSrc)` and those are stable for a mounted image, so a
* ref-freeze keeps the box from flipping when the decoded size is cached
* mid-view — which would re-introduce the shift the reservation prevents.
*/
export function useFrozenImageReserve(
dim: string | undefined,
resolvedSrc: string | undefined,
): ReturnType<typeof resolveImageReserveBox> {
const key = `${dim ?? ""}\u0000${resolvedSrc ?? ""}`;
const ref = React.useRef<{
key: string;
reserve: ReturnType<typeof resolveImageReserveBox>;
} | null>(null);
if (!ref.current || ref.current.key !== key) {
ref.current = { key, reserve: resolveImageReserveBox(dim, resolvedSrc) };
}
return ref.current.reserve;
}
/**
* Inline style for the message image element. A revealed spoiler pins the
* decoded size; a dim-less reserve pins a fixed-height box so a late decode
* letterboxes inside it; otherwise the width/height attributes drive layout.
*/
export function imageReserveStyle(args: {
hiddenSpoilerMediaSize: { height: number; width: number } | null;
intrinsicDimensions: { height: number; width: number };
useFixedReserveBox: boolean;
}): React.CSSProperties | undefined {
const { hiddenSpoilerMediaSize, intrinsicDimensions, useFixedReserveBox } =
args;
if (hiddenSpoilerMediaSize) {
const ratio = `${hiddenSpoilerMediaSize.width} / ${hiddenSpoilerMediaSize.height}`;
return {
"--buzz-spoiler-media-aspect-ratio": ratio,
"--buzz-spoiler-media-width": `${hiddenSpoilerMediaSize.width}px`,
aspectRatio: ratio,
height: "auto",
width: `${hiddenSpoilerMediaSize.width}px`,
} as React.CSSProperties;
}
if (useFixedReserveBox) {
return {
height: `${intrinsicDimensions.height}px`,
width: "min(24rem, 100%)",
};
}
return undefined;
}
export function isInsideHiddenSpoiler(element: Element): boolean {
return (
element.closest('.buzz-spoiler[data-spoiler][data-revealed="false"]') !==
null
);
}
/**
* `urlTransform` for `<ReactMarkdown>` that preserves `buzz://` deep links
* used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…`
* entity links. The default transform strips unknown schemes (returns `""`)
* before the `a` component override can see them, which would break copy →
* paste → click end-to-end.
*
* Policy:
* - `buzz://message` hrefs — preserved unconditionally (handled by the
* message-link pill renderer).
* - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink`
* succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs.
* - Everything else delegates to `defaultUrlTransform`.
*/
export function buzzDeepLinkUrlTransform(value: string, key: string): string {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value) || isChannelLink(value)) return value;
if (parseEntityLink(value).ok) return value;
return defaultUrlTransform(value);
}
/**
* @deprecated Preserved for external callers; use `buzzDeepLinkUrlTransform`
* which also handles `buzz://pr|issue|repo` entity links.
*/
export function messageLinkUrlTransform(value: string, key: string): string {
return buzzDeepLinkUrlTransform(value, key);
}
export function getReactNodeText(node: React.ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getReactNodeText).join("");
}
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
return getReactNodeText(node.props.children);
}
return "";
}