mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
1196 lines
39 KiB
TypeScript
1196 lines
39 KiB
TypeScript
import * as React from "react";
|
|
import ReactMarkdown, {
|
|
type Components,
|
|
defaultUrlTransform,
|
|
} from "react-markdown";
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
import { Copy, Download, FileText } from "lucide-react";
|
|
import remarkBreaks from "remark-breaks";
|
|
import remarkGfm from "remark-gfm";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getSingletonHighlighter,
|
|
type HighlighterGeneric,
|
|
type BundledLanguage,
|
|
type BundledTheme,
|
|
type ThemedToken,
|
|
} from "shiki";
|
|
|
|
import { useTheme } from "@/shared/theme/ThemeProvider";
|
|
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
|
import {
|
|
isMessageLink,
|
|
parseMessageLink,
|
|
type ParsedMessageLink,
|
|
} from "@/features/messages/lib/messageLink";
|
|
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
|
import { invokeTauri } from "@/shared/api/tauri";
|
|
import type { Channel } from "@/shared/api/types";
|
|
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
|
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
|
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
|
|
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
|
|
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
|
|
import remarkCustomEmoji, {
|
|
type CustomEmoji,
|
|
} from "@/shared/lib/remarkCustomEmoji";
|
|
import remarkMentions from "@/shared/lib/remarkMentions";
|
|
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
|
import { Button } from "@/shared/ui/button";
|
|
import {
|
|
MENTION_CHIP_BASE_CLASSES,
|
|
MENTION_CHIP_HOVER_CLASSES,
|
|
} from "@/shared/ui/mentionChip";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
|
|
import {
|
|
classifyChildren,
|
|
hasBlockMedia,
|
|
isImageOnlyParagraph,
|
|
shallowArrayEqual,
|
|
} from "./markdownUtils";
|
|
import { resolveFileCard } from "./markdownFileCard";
|
|
import { VideoPlayer } from "./VideoPlayer";
|
|
|
|
type ImetaEntry = {
|
|
image?: string;
|
|
thumb?: string;
|
|
m?: string;
|
|
size?: number;
|
|
filename?: string;
|
|
};
|
|
|
|
type ImetaLookup = Map<string, ImetaEntry>;
|
|
|
|
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
|
|
null;
|
|
let shikiInitPromise: Promise<void> | null = null;
|
|
const loadedLangs = new Set<string>();
|
|
const loadedThemes = new Set<string>();
|
|
const tokenCache = new Map<string, ThemedToken[][]>();
|
|
const MAX_CACHE_ENTRIES = 100;
|
|
const MAX_LOADED_LANGUAGES = 30;
|
|
const MAX_HIGHLIGHT_LINES = 150;
|
|
const CODE_BLOCK_CLASS =
|
|
"code-block-lines block min-w-full whitespace-pre font-mono text-[13px] leading-6 text-foreground";
|
|
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
|
|
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
|
|
|
|
function ensureHighlighter(): Promise<void> {
|
|
if (shikiHighlighter) return Promise.resolve();
|
|
if (!shikiInitPromise) {
|
|
shikiInitPromise = getSingletonHighlighter({
|
|
themes: [],
|
|
langs: [],
|
|
}).then((h) => {
|
|
shikiHighlighter = h;
|
|
});
|
|
}
|
|
return shikiInitPromise;
|
|
}
|
|
|
|
function extractLanguage(className?: string): string {
|
|
if (typeof className !== "string") return "";
|
|
const match = className.match(/language-(\S+)/);
|
|
return match ? match[1] : "";
|
|
}
|
|
|
|
function stripDiffMarker(tokens: ThemedToken[], marker: RegExp): ThemedToken[] {
|
|
const last = tokens[tokens.length - 1];
|
|
if (!last) return tokens;
|
|
const stripped = last.content.replace(marker, "");
|
|
if (stripped === last.content) return tokens;
|
|
if (stripped === "") return tokens.slice(0, -1);
|
|
return [...tokens.slice(0, -1), { ...last, content: stripped }];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* `urlTransform` for `<ReactMarkdown>` that preserves `sprout://message?…`
|
|
* 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. Everything else delegates to `defaultUrlTransform`.
|
|
*/
|
|
function messageLinkUrlTransform(value: string, key: string): string {
|
|
if (key === "href" && isMessageLink(value)) {
|
|
return value;
|
|
}
|
|
return defaultUrlTransform(value);
|
|
}
|
|
|
|
type MarkdownProps = {
|
|
channelNames?: string[];
|
|
className?: string;
|
|
compact?: boolean;
|
|
content: string;
|
|
customEmoji?: CustomEmoji[];
|
|
imetaByUrl?: ImetaLookup;
|
|
interactive?: boolean;
|
|
mentionNames?: string[];
|
|
mentionPubkeysByName?: Record<string, string>;
|
|
searchQuery?: string;
|
|
tight?: boolean;
|
|
};
|
|
|
|
type MarkdownVariant = "default" | "compact" | "tight";
|
|
|
|
/**
|
|
* Inline image embed with click-to-zoom lightbox and right-click download.
|
|
*
|
|
* IMPORTANT: this component renders the inline `<img>` with NO wrapping div
|
|
* and drives the Dialog via controlled `open` state — *not* via Radix's
|
|
* `<Trigger asChild>` cloning onto a wrapper div. An earlier version used
|
|
* the Trigger-asChild pattern around a `<div>` around the `<img>`, which
|
|
* caused a 1-2px layout reflow in the surrounding message body on hover
|
|
* (the gap between the username header and the body would visibly grow).
|
|
* The nested wrapper divs + Radix attribute cloning were repainting the
|
|
* surrounding inline flow on `:hover` state changes. Keeping the `<img>`
|
|
* bare and managing the lightbox + context menu via React state avoids it.
|
|
*/
|
|
function ImageBlock({
|
|
alt,
|
|
resolvedSrc,
|
|
src,
|
|
}: {
|
|
alt: string | undefined;
|
|
resolvedSrc: string | undefined;
|
|
src: string | undefined;
|
|
}) {
|
|
const [lightboxOpen, setLightboxOpen] = React.useState(false);
|
|
const [menu, setMenu] = React.useState<{ x: number; y: number } | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!menu) return;
|
|
const close = () => setMenu(null);
|
|
// Defer attaching the dismiss listeners until after the current event
|
|
// loop turn. The right-click that opens the menu (a `contextmenu` on
|
|
// mousedown) is often followed by a trailing `click`/`pointerup` on the
|
|
// same interaction; attaching synchronously lets that trailing event —
|
|
// and the platform `click` some webviews emit on right-button release —
|
|
// immediately dismiss the menu, so it only flashes. Deferring guarantees
|
|
// the opening interaction can never be the one that closes it.
|
|
let attached = false;
|
|
const timer = window.setTimeout(() => {
|
|
attached = true;
|
|
window.addEventListener("click", close);
|
|
window.addEventListener("contextmenu", close);
|
|
window.addEventListener("scroll", close, true);
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
if (attached) {
|
|
window.removeEventListener("click", close);
|
|
window.removeEventListener("contextmenu", close);
|
|
window.removeEventListener("scroll", close, true);
|
|
}
|
|
};
|
|
}, [menu]);
|
|
|
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.nativeEvent.stopImmediatePropagation();
|
|
setMenu({ x: e.clientX, y: e.clientY });
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
setMenu(null);
|
|
if (!src) return;
|
|
invokeTauri("download_image", { url: src }).catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : "Download failed";
|
|
toast.error(msg);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */}
|
|
<img
|
|
alt={alt}
|
|
className="mt-1 block max-h-64 max-w-sm cursor-pointer rounded-xl object-contain"
|
|
src={resolvedSrc}
|
|
onClick={() => setLightboxOpen(true)}
|
|
onContextMenuCapture={handleContextMenu}
|
|
/>
|
|
{menu && src ? (
|
|
<div
|
|
className="fixed z-[100] min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
|
style={{ left: menu.x, top: menu.y }}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex w-full cursor-default select-none items-center rounded-xs px-2 py-1.5 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground"
|
|
onClick={handleDownload}
|
|
>
|
|
Download image
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<DialogPrimitive.Root open={lightboxOpen} onOpenChange={setLightboxOpen}>
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
<DialogPrimitive.Content
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-8"
|
|
onPointerDownOutside={(e) => e.preventDefault()}
|
|
onInteractOutside={(e) => e.preventDefault()}
|
|
>
|
|
<DialogPrimitive.Title className="sr-only">
|
|
{alt || "Image preview"}
|
|
</DialogPrimitive.Title>
|
|
<DialogPrimitive.Description className="sr-only">
|
|
Full-size image preview. Press Escape or click outside the image
|
|
to close.
|
|
</DialogPrimitive.Description>
|
|
{/* Clicking anywhere except the image closes the dialog. */}
|
|
<DialogPrimitive.Close
|
|
className="absolute inset-0 cursor-default"
|
|
aria-label="Close lightbox"
|
|
/>
|
|
<img
|
|
alt={alt}
|
|
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
|
src={resolvedSrc}
|
|
onContextMenuCapture={handleContextMenu}
|
|
/>
|
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
<span className="sr-only">Close</span>
|
|
</DialogPrimitive.Close>
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
</DialogPrimitive.Root>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
|
|
function getCodeBlockText(children: React.ReactNode) {
|
|
return getReactNodeText(children).replace(/\n$/, "");
|
|
}
|
|
|
|
function InlineEmojiPopover({
|
|
alt,
|
|
resolvedSrc,
|
|
}: {
|
|
alt: string | undefined;
|
|
resolvedSrc: string;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const openTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const label = alt?.trim() || "Custom emoji";
|
|
|
|
const clearTimers = React.useCallback(() => {
|
|
if (openTimeout.current) {
|
|
clearTimeout(openTimeout.current);
|
|
openTimeout.current = null;
|
|
}
|
|
if (closeTimeout.current) {
|
|
clearTimeout(closeTimeout.current);
|
|
closeTimeout.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handleMouseEnter = React.useCallback(() => {
|
|
clearTimers();
|
|
openTimeout.current = setTimeout(() => setOpen(true), 200);
|
|
}, [clearTimers]);
|
|
|
|
const scheduleClose = React.useCallback(() => {
|
|
clearTimers();
|
|
closeTimeout.current = setTimeout(() => setOpen(false), 150);
|
|
}, [clearTimers]);
|
|
|
|
const handleFocus = React.useCallback(() => {
|
|
clearTimers();
|
|
setOpen(true);
|
|
}, [clearTimers]);
|
|
|
|
React.useEffect(() => clearTimers, [clearTimers]);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex border-0 bg-transparent p-0 align-middle text-inherit"
|
|
aria-label={label}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={scheduleClose}
|
|
onFocus={handleFocus}
|
|
onBlur={scheduleClose}
|
|
>
|
|
<img
|
|
alt={alt}
|
|
title={label}
|
|
src={resolvedSrc}
|
|
data-custom-emoji=""
|
|
className="mx-px inline-block h-[1.25em] w-auto max-w-none align-middle"
|
|
draggable={false}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="center"
|
|
side="top"
|
|
sideOffset={6}
|
|
className="w-auto min-w-32 max-w-56 rounded-xl p-3"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={scheduleClose}
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="mb-2 flex h-14 w-14 items-center justify-center">
|
|
<img
|
|
alt={alt}
|
|
src={resolvedSrc}
|
|
className="inline-block h-12 w-12 object-contain"
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
<div className="max-w-[12rem] text-balance text-sm font-semibold leading-snug text-popover-foreground">
|
|
{label}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function MarkdownCodeBlock({
|
|
children,
|
|
language,
|
|
}: {
|
|
children?: React.ReactNode;
|
|
language?: string;
|
|
}) {
|
|
const [isCopying, setIsCopying] = React.useState(false);
|
|
const code = React.useMemo(() => getCodeBlockText(children), [children]);
|
|
|
|
const handleCopy = React.useCallback(
|
|
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsCopying(true);
|
|
|
|
try {
|
|
await copyCodeBlockToClipboard(code);
|
|
toast.success("Copied code to clipboard");
|
|
} catch (error) {
|
|
console.error("Failed to copy code block", error);
|
|
toast.error("Failed to copy code");
|
|
} finally {
|
|
setIsCopying(false);
|
|
}
|
|
},
|
|
[code],
|
|
);
|
|
|
|
return (
|
|
<div className="group relative" data-code-block="">
|
|
<pre className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs">
|
|
{language && (
|
|
<div className="mb-1 text-xs text-muted-foreground/70">
|
|
{language}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</pre>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
aria-label="Copy code block"
|
|
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-xs ring-1 ring-border/60 backdrop-blur-sm transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
|
|
disabled={isCopying}
|
|
onClick={handleCopy}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
<span className="sr-only">Copy code block</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Copy code</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */
|
|
function formatFileSize(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KB", "MB", "GB", "TB"];
|
|
let size = bytes / 1024;
|
|
let i = 0;
|
|
while (size >= 1024 && i < units.length - 1) {
|
|
size /= 1024;
|
|
i += 1;
|
|
}
|
|
return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
|
|
}
|
|
|
|
/**
|
|
* File card for a generic (non-image, non-video) attachment: icon, filename,
|
|
* size, and a download action.
|
|
*
|
|
* Downloads go through the native `download_file` Tauri command (HTTP inside
|
|
* the app's tunnel + a save dialog), not a plain `<a download>` link. A bare
|
|
* link navigates the webview to the blob URL, which escapes to the OS browser
|
|
* and gets bounced to a corporate CDN interstitial ("browser not supported").
|
|
* The native command mirrors the image-download path.
|
|
*/
|
|
function FileCard({
|
|
href,
|
|
filename,
|
|
size,
|
|
}: {
|
|
href: string;
|
|
filename: string;
|
|
size?: number;
|
|
}) {
|
|
const sizeLabel = size != null ? formatFileSize(size) : "";
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
invokeTauri("download_file", { url: href, filename }).catch(
|
|
(err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : "Download failed";
|
|
toast.error(msg);
|
|
},
|
|
);
|
|
}}
|
|
data-testid="file-card"
|
|
className="my-1 inline-flex max-w-sm items-center gap-3 rounded-xl border border-border/70 bg-muted/40 px-3 py-2 text-left no-underline transition-colors hover:bg-muted/70"
|
|
>
|
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-background text-muted-foreground">
|
|
<FileText className="h-5 w-5" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium text-foreground">
|
|
{filename}
|
|
</span>
|
|
{sizeLabel ? (
|
|
<span className="block text-xs text-muted-foreground">
|
|
{sizeLabel}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<Download className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SyntaxHighlightedCode({
|
|
code,
|
|
language,
|
|
...props
|
|
}: {
|
|
code: string;
|
|
language: string;
|
|
} & React.ComponentProps<"code">) {
|
|
const { themeName } = useTheme();
|
|
const [loadedKey, setLoadedKey] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadAssets() {
|
|
try {
|
|
await ensureHighlighter();
|
|
if (!shikiHighlighter || cancelled) return;
|
|
let loaded = false;
|
|
if (!loadedLangs.has(language)) {
|
|
if (loadedLangs.size >= MAX_LOADED_LANGUAGES) return;
|
|
try {
|
|
await shikiHighlighter.loadLanguage(language as BundledLanguage);
|
|
loadedLangs.add(language);
|
|
loaded = true;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
if (!loadedThemes.has(themeName as string)) {
|
|
try {
|
|
await shikiHighlighter.loadTheme(themeName as BundledTheme);
|
|
loadedThemes.add(themeName as string);
|
|
loaded = true;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
if (loaded && !cancelled) setLoadedKey((k) => k + 1);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (!loadedLangs.has(language) || !loadedThemes.has(themeName as string)) {
|
|
loadAssets();
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [language, themeName]);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading
|
|
const tokens = React.useMemo(() => {
|
|
if (
|
|
!shikiHighlighter ||
|
|
!loadedLangs.has(language) ||
|
|
!loadedThemes.has(themeName as string)
|
|
)
|
|
return null;
|
|
if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null;
|
|
const cacheKey = `${language}:${themeName}:${code}`;
|
|
const cached = tokenCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
try {
|
|
const result = shikiHighlighter.codeToTokens(code, {
|
|
lang: language as BundledLanguage,
|
|
theme: themeName as BundledTheme,
|
|
});
|
|
if (tokenCache.size >= MAX_CACHE_ENTRIES) {
|
|
const firstKey = tokenCache.keys().next().value;
|
|
if (firstKey !== undefined) tokenCache.delete(firstKey);
|
|
}
|
|
tokenCache.set(cacheKey, result.tokens);
|
|
return result.tokens;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, [code, language, themeName, loadedKey]);
|
|
|
|
const codeClassName = CODE_BLOCK_CLASS;
|
|
|
|
if (!tokens) {
|
|
const lines = code.split("\n");
|
|
return (
|
|
<code {...props} className={codeClassName}>
|
|
{lines.map((line, i) => (
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
|
|
<span key={i} data-line="">
|
|
{line}
|
|
</span>
|
|
))}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<code {...props} className={codeClassName}>
|
|
{tokens.map((line, lineIdx) => {
|
|
const lineText = line.map((t) => t.content).join("");
|
|
const isAdd = DIFF_ADD_RE.test(lineText);
|
|
const isRemove = DIFF_REMOVE_RE.test(lineText);
|
|
const diffClass = isAdd
|
|
? "code-line-diff-add"
|
|
: isRemove
|
|
? "code-line-diff-remove"
|
|
: undefined;
|
|
|
|
const renderedTokens =
|
|
isAdd || isRemove
|
|
? stripDiffMarker(line, isAdd ? DIFF_ADD_RE : DIFF_REMOVE_RE)
|
|
: line;
|
|
|
|
return (
|
|
<span
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
|
key={lineIdx}
|
|
data-line=""
|
|
className={diffClass}
|
|
>
|
|
{renderedTokens.map((token, tokenIdx) => (
|
|
<span
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
|
key={tokenIdx}
|
|
style={token.color ? { color: token.color } : undefined}
|
|
>
|
|
{token.content}
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
})}
|
|
</code>
|
|
);
|
|
}
|
|
function createMarkdownComponents(
|
|
variant: MarkdownVariant,
|
|
channels: Channel[],
|
|
onOpenChannel: (channelId: string) => void,
|
|
onOpenMessageLink: (link: ParsedMessageLink) => void,
|
|
imetaByUrl?: ImetaLookup,
|
|
mentionPubkeysByName?: Record<string, string>,
|
|
interactive = true,
|
|
): Components {
|
|
const paragraphClassName =
|
|
variant === "tight"
|
|
? "leading-5"
|
|
: variant === "compact"
|
|
? "leading-6"
|
|
: "leading-7";
|
|
const listItemClassName =
|
|
variant === "tight" ? "my-0.5 [&_p]:inline" : "my-1 [&_p]:inline";
|
|
const listClassName =
|
|
variant === "tight"
|
|
? "space-y-0.5 pl-6 marker:text-muted-foreground"
|
|
: "space-y-1 pl-6 marker:text-muted-foreground";
|
|
|
|
return {
|
|
a: ({ children, href, ...props }) => {
|
|
if (!interactive) {
|
|
return <span className="font-medium text-current">{children}</span>;
|
|
}
|
|
|
|
// Generic file attachment: a `[filename](url)` link whose href matches an
|
|
// imeta entry with a non-image, non-video MIME. Render a download card
|
|
// instead of a plain link. (Media uses the `img` renderer, not this path.)
|
|
const card = resolveFileCard(
|
|
href ? imetaByUrl?.get(href) : undefined,
|
|
href,
|
|
getReactNodeText(children),
|
|
);
|
|
if (card) {
|
|
return (
|
|
<FileCard
|
|
href={card.href}
|
|
filename={card.filename}
|
|
size={card.size}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Intercept `sprout://message?channel=…&id=…` links so a click navigates
|
|
// in-app instead of opening the URL in the OS browser. http(s) links
|
|
// continue to use the existing target="_blank" behavior.
|
|
if (isMessageLink(href)) {
|
|
const parsed = parseMessageLink(href ?? "");
|
|
if (parsed.ok) {
|
|
const target = parsed.value;
|
|
return (
|
|
<a
|
|
{...props}
|
|
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
|
|
href={href}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
onOpenMessageLink(target);
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
// Malformed sprout://message link — fall through to the default
|
|
// anchor (renders as a normal external link).
|
|
}
|
|
return (
|
|
<a
|
|
{...props}
|
|
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
|
|
href={href}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
},
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground [&>*:first-child]:mt-0 [&>*+*]:mt-2">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
br: () => <br />,
|
|
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
|
|
const rawCode = String(children);
|
|
const code = rawCode.replace(/\n$/, "");
|
|
const isFencedCodeBlock =
|
|
typeof className === "string" && className.includes("language-");
|
|
|
|
if (isFencedCodeBlock || rawCode.endsWith("\n") || code.includes("\n")) {
|
|
const language = extractLanguage(className);
|
|
|
|
if (language) {
|
|
return (
|
|
<SyntaxHighlightedCode code={code} language={language} {...props} />
|
|
);
|
|
}
|
|
|
|
const lines = code.split("\n");
|
|
return (
|
|
<code {...props} className={CODE_BLOCK_CLASS}>
|
|
{lines.map((line, i) => (
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
|
|
<span key={i} data-line="">
|
|
{line}
|
|
</span>
|
|
))}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<code
|
|
{...props}
|
|
className={cn(
|
|
"rounded-md bg-muted px-1.5 py-0.5 font-mono text-[13px] text-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
h1: ({ children }) => (
|
|
<h1 className="text-xl font-semibold leading-8 tracking-tight">
|
|
{children}
|
|
</h1>
|
|
),
|
|
h2: ({ children }) => (
|
|
<h2 className="text-lg font-semibold leading-7 tracking-tight">
|
|
{children}
|
|
</h2>
|
|
),
|
|
h3: ({ children }) => (
|
|
<h3 className="text-base font-semibold leading-6 tracking-tight">
|
|
{children}
|
|
</h3>
|
|
),
|
|
hr: () => <hr className="border-border/80" />,
|
|
img: ({ alt, src }) => {
|
|
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
|
|
if (!interactive) {
|
|
const fallbackLabel = resolvedSrc?.endsWith(".mp4")
|
|
? "Video attachment"
|
|
: "Image attachment";
|
|
return <span>{alt?.trim() || fallbackLabel}</span>;
|
|
}
|
|
|
|
if (resolvedSrc?.endsWith(".mp4")) {
|
|
// Look up poster frame from imeta tags (NIP-71 `image` field).
|
|
// Fall back to `thumb` for compatibility with older events.
|
|
const entry = src ? imetaByUrl?.get(src) : undefined;
|
|
const posterUrl = entry?.image ?? entry?.thumb;
|
|
const resolvedPoster = posterUrl
|
|
? rewriteRelayUrl(posterUrl)
|
|
: undefined;
|
|
return (
|
|
<span data-block-media="">
|
|
<VideoPlayer
|
|
key={resolvedSrc}
|
|
src={resolvedSrc}
|
|
poster={resolvedPoster}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span data-block-media="" className="block">
|
|
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
|
|
</span>
|
|
);
|
|
},
|
|
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
|
|
ol: ({ children }) => (
|
|
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
|
|
),
|
|
p: ({ children }) => {
|
|
// Detect image-only paragraphs (images + <br> from remarkBreaks).
|
|
// Multi-image: render as a 2-column grid gallery.
|
|
// Single media: render as a plain <div> to avoid invalid <p><div> nesting
|
|
// (the img component returns block-level wrappers for lightbox/video).
|
|
const childArray = React.Children.toArray(children);
|
|
const { imageChildren } = classifyChildren(childArray);
|
|
|
|
if (isImageOnlyParagraph(childArray)) {
|
|
return (
|
|
<div className="mt-1 grid max-w-lg grid-cols-2 gap-1.5 [&_br]:hidden [&_[data-block-media]]:mt-0 [&_[data-block-media]]:max-w-none [&_img]:mt-0 [&_img]:w-full [&_img]:max-w-full">
|
|
{imageChildren}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (hasBlockMedia(childArray)) {
|
|
return <div className={paragraphClassName}>{children}</div>;
|
|
}
|
|
|
|
return <p className={paragraphClassName}>{children}</p>;
|
|
},
|
|
pre: ({ children }) => {
|
|
if (!interactive) return <span>{children}</span>;
|
|
let language = "";
|
|
React.Children.forEach(children, (child) => {
|
|
if (
|
|
React.isValidElement<Record<string, unknown>>(child) &&
|
|
typeof child.props?.className === "string"
|
|
) {
|
|
language = extractLanguage(child.props.className);
|
|
}
|
|
});
|
|
return (
|
|
<MarkdownCodeBlock language={language}>{children}</MarkdownCodeBlock>
|
|
);
|
|
},
|
|
strong: ({ children }) => (
|
|
<strong className="font-semibold">{children}</strong>
|
|
),
|
|
table: ({ children }) => (
|
|
<div
|
|
className="overflow-x-auto rounded-2xl border border-border/70"
|
|
data-table-block=""
|
|
>
|
|
<table className="w-full border-collapse text-left text-sm">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="border-t border-border/70 px-3 py-2 align-top">
|
|
{children}
|
|
</td>
|
|
),
|
|
th: ({ children }) => (
|
|
<th className="bg-muted/60 px-3 py-2 font-semibold text-foreground">
|
|
{children}
|
|
</th>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className={cn("list-disc", listClassName)}>{children}</ul>
|
|
),
|
|
mention: ({ children }: { children?: React.ReactNode }) => {
|
|
const mentionText = String(children ?? "");
|
|
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
|
const pubkey = mentionPubkeysByName?.[mentionName];
|
|
const mentionNode = (
|
|
<span
|
|
data-mention=""
|
|
className={cn(
|
|
"cursor-pointer",
|
|
MENTION_CHIP_BASE_CLASSES,
|
|
MENTION_CHIP_HOVER_CLASSES,
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
|
|
if (!interactive) {
|
|
return mentionNode;
|
|
}
|
|
|
|
return pubkey ? (
|
|
<UserProfilePopover pubkey={pubkey} triggerElement="span">
|
|
{mentionNode}
|
|
</UserProfilePopover>
|
|
) : (
|
|
mentionNode
|
|
);
|
|
},
|
|
emoji: ({ src, alt }: { src?: string; alt?: string }) => {
|
|
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
|
|
if (!resolvedSrc) {
|
|
return <span>{alt}</span>;
|
|
}
|
|
if (!interactive) {
|
|
return <span>{alt}</span>;
|
|
}
|
|
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
|
|
},
|
|
"channel-link": ({ children }: { children?: React.ReactNode }) => {
|
|
const text = String(children ?? "");
|
|
const channelName = text.startsWith("#") ? text.slice(1) : text;
|
|
const channel = channels.find(
|
|
(c) =>
|
|
c.channelType !== "dm" &&
|
|
c.name.toLowerCase() === channelName.toLowerCase(),
|
|
);
|
|
|
|
if (channel && interactive) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-channel-link=""
|
|
aria-label={`Open channel ${channelName}`}
|
|
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors"
|
|
onClick={() => {
|
|
onOpenChannel(channel.id);
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
data-channel-link=""
|
|
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary"
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
},
|
|
"message-link": ({ children }: { children?: React.ReactNode }) => {
|
|
const href = String(children ?? "");
|
|
const parsed = parseMessageLink(href);
|
|
if (!parsed.ok) {
|
|
// Malformed `sprout://message?…` — render the raw URL as plain text
|
|
// rather than a misleading clickable pill.
|
|
return <span data-message-link="">{href}</span>;
|
|
}
|
|
|
|
const { channelId, messageId } = parsed.value;
|
|
const channel = channels.find((c) => c.id === channelId);
|
|
const channelLabel = channel?.name ?? "channel";
|
|
const shortId = messageId.slice(0, 6);
|
|
|
|
if (!interactive) {
|
|
return (
|
|
<span data-message-link="">
|
|
#{channelLabel} · {shortId}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-message-link=""
|
|
aria-label={`Open message in ${channelLabel}`}
|
|
title={href}
|
|
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors"
|
|
onClick={() => {
|
|
onOpenMessageLink(parsed.value);
|
|
}}
|
|
>
|
|
#{channelLabel} · {shortId}
|
|
</button>
|
|
);
|
|
},
|
|
} as Components;
|
|
}
|
|
|
|
function MarkdownInner({
|
|
channelNames,
|
|
className,
|
|
compact = false,
|
|
content,
|
|
customEmoji,
|
|
imetaByUrl,
|
|
interactive = true,
|
|
mentionNames,
|
|
mentionPubkeysByName,
|
|
searchQuery,
|
|
tight = false,
|
|
}: MarkdownProps) {
|
|
const variant: MarkdownVariant = tight
|
|
? "tight"
|
|
: compact
|
|
? "compact"
|
|
: "default";
|
|
const { channels: rawChannels } = useChannelNavigation();
|
|
const channels = useStableArray(rawChannels);
|
|
const { goChannel } = useAppNavigation();
|
|
|
|
const components = React.useMemo(
|
|
() =>
|
|
createMarkdownComponents(
|
|
variant,
|
|
channels,
|
|
(channelId) => {
|
|
void goChannel(channelId);
|
|
},
|
|
(link) => {
|
|
// Always route through `goChannel` with `messageId` set: the
|
|
// channel route already handles scroll-into-view + highlight via
|
|
// `useTimelineScrollManager` + `getEventById` backfill, and works
|
|
// for both stream-message replies and forum threads. Detecting
|
|
// "the thread root is a forum post" up front would require an
|
|
// event lookup we don't currently have synchronously; the brief
|
|
// explicitly allows skipping that detection and falling through.
|
|
void goChannel(link.channelId, {
|
|
messageId: link.messageId,
|
|
threadRootId: link.threadRootId,
|
|
});
|
|
},
|
|
imetaByUrl,
|
|
mentionPubkeysByName,
|
|
interactive,
|
|
),
|
|
[
|
|
goChannel,
|
|
variant,
|
|
channels,
|
|
imetaByUrl,
|
|
mentionPubkeysByName,
|
|
interactive,
|
|
],
|
|
);
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
|
const remarkPlugins = React.useMemo<any[]>(
|
|
() => [
|
|
remarkGfm,
|
|
remarkBreaks,
|
|
remarkMessageLinks,
|
|
[remarkMentions, { mentionNames }],
|
|
[remarkChannelLinks, { channelNames }],
|
|
[remarkCustomEmoji, { customEmoji }],
|
|
],
|
|
[mentionNames, channelNames, customEmoji],
|
|
);
|
|
|
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
|
const rehypePlugins = React.useMemo<any[]>(() => {
|
|
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
|
|
const plugins: any[] = [rehypeImageGallery];
|
|
if (searchQuery && searchQuery.trim().length >= 2) {
|
|
plugins.push([rehypeSearchHighlight, { query: searchQuery }]);
|
|
}
|
|
return plugins;
|
|
}, [searchQuery]);
|
|
|
|
let processedContent = content;
|
|
|
|
if (/^(?:\s{2}\n)+/.test(content)) {
|
|
processedContent = `\u200B${processedContent}`;
|
|
}
|
|
|
|
if (/(?:\s{2}\n)+$/.test(content)) {
|
|
processedContent = `${processedContent}\u200B`;
|
|
}
|
|
|
|
const markdownNode = (
|
|
<ReactMarkdown
|
|
components={components}
|
|
remarkPlugins={remarkPlugins}
|
|
rehypePlugins={rehypePlugins}
|
|
urlTransform={messageLinkUrlTransform}
|
|
>
|
|
{processedContent}
|
|
</ReactMarkdown>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
tight
|
|
? [
|
|
"max-w-none break-words text-sm leading-5 text-foreground/90",
|
|
// Reset first/last
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
// Base owl: p+p, list+p, etc.
|
|
"[&>*+*]:mt-2",
|
|
// Headings: flat push/pull — size does the hierarchy work
|
|
"[&>*+h1]:mt-2.5 [&>*+h2]:mt-2.5 [&>*+h3]:mt-2.5",
|
|
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5",
|
|
// Blockquotes: breathe above and below
|
|
"[&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3",
|
|
// Code blocks: breathe above and below
|
|
"[&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3",
|
|
// Tables: breathe above and below
|
|
"[&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3",
|
|
// hr: clear section divider
|
|
"[&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5",
|
|
// Lists after paragraphs: tighter to feel related
|
|
"[&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
|
|
].join(" ")
|
|
: compact
|
|
? [
|
|
"max-w-none break-words text-[15px] leading-6 text-foreground/90",
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
"[&>*+*]:mt-2",
|
|
"[&>*+h1]:mt-3 [&>*+h2]:mt-3 [&>*+h3]:mt-3",
|
|
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5",
|
|
"[&>*+blockquote]:mt-3 [&>blockquote+*]:mt-3",
|
|
"[&>*+[data-code-block]]:mt-3 [&>[data-code-block]+*]:mt-3",
|
|
"[&>*+[data-table-block]]:mt-3 [&>[data-table-block]+*]:mt-3",
|
|
"[&>*+hr]:mt-3.5 [&>hr+*]:mt-3.5",
|
|
"[&>p+ul]:mt-1 [&>p+ol]:mt-1 [&>div+ul]:mt-1 [&>div+ol]:mt-1",
|
|
].join(" ")
|
|
: [
|
|
"max-w-none break-words text-sm leading-7 text-foreground/90",
|
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
|
"[&>*+*]:mt-3",
|
|
"[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5",
|
|
"[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5",
|
|
"[&>*+blockquote]:mt-3.5 [&>blockquote+*]:mt-3.5",
|
|
"[&>*+[data-code-block]]:mt-3.5 [&>[data-code-block]+*]:mt-3.5",
|
|
"[&>*+[data-table-block]]:mt-3.5 [&>[data-table-block]+*]:mt-3.5",
|
|
"[&>*+hr]:mt-4 [&>hr+*]:mt-4",
|
|
"[&>p+ul]:mt-1.5 [&>p+ol]:mt-1.5 [&>div+ul]:mt-1.5 [&>div+ol]:mt-1.5",
|
|
].join(" "),
|
|
className,
|
|
)}
|
|
>
|
|
{markdownNode}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Markdown = React.memo(
|
|
MarkdownInner,
|
|
(prev, next) =>
|
|
prev.content === next.content &&
|
|
prev.className === next.className &&
|
|
prev.compact === next.compact &&
|
|
prev.customEmoji === next.customEmoji &&
|
|
prev.interactive === next.interactive &&
|
|
prev.tight === next.tight &&
|
|
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
|
|
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
|
|
shallowArrayEqual(prev.channelNames, next.channelNames) &&
|
|
prev.imetaByUrl === next.imetaByUrl &&
|
|
prev.searchQuery === next.searchQuery,
|
|
);
|
|
|
|
Markdown.displayName = "Markdown";
|