From 2229c55ee62fd15b6c606c9533112bfe15840e2b Mon Sep 17 00:00:00 2001 From: aqua427 <216757359+aqua427@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:42:38 -0500 Subject: [PATCH] Basic Markdown support --- .../components/message-with-mentions.test.tsx | 92 ++++ src/components/message-with-mentions.tsx | 408 ++++++++++++++++-- src/components/search-results.tsx | 4 +- 3 files changed, 471 insertions(+), 33 deletions(-) diff --git a/src/__tests__/components/message-with-mentions.test.tsx b/src/__tests__/components/message-with-mentions.test.tsx index a9816533..c6f2f1f6 100644 --- a/src/__tests__/components/message-with-mentions.test.tsx +++ b/src/__tests__/components/message-with-mentions.test.tsx @@ -185,4 +185,96 @@ describe("MessageWithMentions", () => { // Standard emoji should still work expect(container.textContent).toContain("😄"); }); + + it("should render basic markdown formatting", () => { + render( + + ); + + expect(screen.getByText("Bold").closest("strong")).not.toBeNull(); + expect(screen.getByText("Italic").closest("em")).not.toBeNull(); + expect(screen.getByText("Struck").closest("del")).not.toBeNull(); + }); + + it("should render safe markdown links with secure attributes", () => { + render( + + ); + + const link = screen.getByRole("link", { name: "Firepit" }); + expect(link.getAttribute("href")).toBe("https://example.com/docs"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("should not render unsafe javascript links", () => { + render( + + ); + + expect(screen.queryByRole("link", { name: "click" })).toBeNull(); + expect(screen.getByText("click")).toBeInTheDocument(); + }); + + it("should preserve mention highlighting inside markdown", () => { + const users = new Map([ + [ + "user-2", + { + userId: "user-2", + displayName: "TestUser", + avatarUrl: "", + status: "online", + pronouns: "they/them", + }, + ], + ]); + + render( + + ); + + const mention = screen.getByTitle("TestUser (they/them)"); + expect(mention.className).toContain("font-semibold"); + expect(mention.closest("strong")).not.toBeNull(); + }); + + it("should not convert shortcode emojis inside inline code", () => { + const customEmojis = [ + { + fileId: "emoji-1", + url: "/api/emoji/emoji-1", + name: "party-parrot", + }, + ]; + + const { container } = render( + + ); + + const code = container.querySelector("code"); + expect(code).not.toBeNull(); + expect(code?.textContent).toBe(":party-parrot:"); + expect(screen.getByRole("img").getAttribute("alt")).toBe( + ":party-parrot:", + ); + }); }); diff --git a/src/components/message-with-mentions.tsx b/src/components/message-with-mentions.tsx index 2ad9a685..2dddc9ff 100644 --- a/src/components/message-with-mentions.tsx +++ b/src/components/message-with-mentions.tsx @@ -1,10 +1,25 @@ "use client"; +import { Children, cloneElement, isValidElement } from "react"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + import type { MentionMatch } from "@/lib/mention-utils"; import { parseMentions } from "@/lib/mention-utils"; import { EmojiRenderer } from "@/components/emoji-renderer"; import type { UserProfileData, CustomEmoji } from "@/lib/types"; +const MARKDOWN_PATTERN = + /(\*\*|__|~~|`|\[[^\]]+\]\([^)]+\)|^\s{0,3}(?:[-+*]|\d+\.)\s+|^\s{0,3}>\s+|^\s{0,3}#{1,6}\s+)/m; + +const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +type MentionToken = { + token: string; + element: React.ReactElement; +}; + interface MessageWithMentionsProps { text: string; mentions?: string[]; // Display names of mentioned users (from the stored message) @@ -31,64 +46,395 @@ export function MessageWithMentions({ customEmojis = [], }: MessageWithMentionsProps) { const allMatches = findMentionSpans(text, mentions, knownNames); + const mentionTokens = createMentionTokens({ + text, + matches: allMatches, + users, + currentUserId, + }); + + const textWithTokens = injectMentionTokens(text, allMatches, mentionTokens); + + return ( +
+ {renderMessageText({ + customEmojis, + mentionTokens, + text: textWithTokens, + })} +
+ ); +} + +function hasMarkdownSyntax(text: string): boolean { + return MARKDOWN_PATTERN.test(text); +} - if (allMatches.length === 0) { +function sanitizeLinkHref(href: string | undefined): string | null { + if (!href) { + return null; + } + + const normalizedHref = href.trim(); + + if (!normalizedHref) { + return null; + } + + if ( + normalizedHref.startsWith("/") || + normalizedHref.startsWith("#") || + normalizedHref.startsWith("?") + ) { + return normalizedHref; + } + + try { + const parsed = new URL(normalizedHref); + if (SAFE_LINK_PROTOCOLS.has(parsed.protocol)) { + return normalizedHref; + } + } catch { + return null; + } + + return null; +} + +function renderMessageText({ + text, + customEmojis, + mentionTokens, +}: { + text: string; + customEmojis: CustomEmoji[]; + mentionTokens: MentionToken[]; +}) { + if (!hasMarkdownSyntax(text)) { + return renderDecoratedText({ text, customEmojis, mentionTokens }); + } + + return ( + ( +

+ {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} +

+ ), + strong: ({ children }) => ( + + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} + + ), + em: ({ children }) => ( + + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} + + ), + del: ({ children }) => ( + + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} + + ), + ul: ({ children }) => ( +
    + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} +
+ ), + ol: ({ children }) => ( +
    + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} +
+ ), + li: ({ children }) => ( +
  • + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} +
  • + ), + blockquote: ({ children }) => ( +
    + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} +
    + ), + pre: ({ children }) => ( +
    +                        {children}
    +                    
    + ), + a: ({ children, href }) => { + const safeHref = sanitizeLinkHref(href); + + if (!safeHref) { + return ( + + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} + + ); + } + + const isExternal = + safeHref.startsWith("http://") || + safeHref.startsWith("https://"); + + return ( + + {renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, + })} + + ); + }, + img: ({ alt }) => ( + + [{alt || "image"}] + + ), + }} + > + {text} +
    + ); +} + +function renderMarkdownChildren({ + children, + customEmojis, + mentionTokens, +}: { + children: React.ReactNode; + customEmojis: CustomEmoji[]; + mentionTokens: MentionToken[]; +}): React.ReactNode { + return Children.map(children, (child) => { + if (typeof child === "string" || typeof child === "number") { + return renderDecoratedText({ + text: String(child), + customEmojis, + mentionTokens, + }); + } + + if ( + isValidElement<{ children?: React.ReactNode }>(child) && + child.props.children !== undefined + ) { + if ( + typeof child.type === "string" && + (child.type === "code" || child.type === "pre") + ) { + return child; + } + + return cloneElement( + child, + undefined, + renderMarkdownChildren({ + children: child.props.children, + customEmojis, + mentionTokens, + }), + ); + } + + return child; + }); +} + +function renderDecoratedText({ + text, + customEmojis, + mentionTokens, +}: { + text: string; + customEmojis: CustomEmoji[]; + mentionTokens: MentionToken[]; +}): React.ReactNode { + if (mentionTokens.length === 0) { return ; } const parts: React.ReactNode[] = []; - let lastIndex = 0; + let cursor = 0; - for (const [index, match] of allMatches.entries()) { - if (match.startIndex > lastIndex) { + while (cursor < text.length) { + let nextMatch: + | { + token: string; + index: number; + element: React.ReactElement; + } + | null = null; + + for (const mentionToken of mentionTokens) { + const tokenIndex = text.indexOf(mentionToken.token, cursor); + if (tokenIndex === -1) { + continue; + } + + if (!nextMatch || tokenIndex < nextMatch.index) { + nextMatch = { + token: mentionToken.token, + index: tokenIndex, + element: mentionToken.element, + }; + } + } + + if (!nextMatch) { + parts.push( + , + ); + break; + } + + if (nextMatch.index > cursor) { parts.push( , ); } + parts.push( + cloneElement(nextMatch.element, { + key: `mention-${nextMatch.index}`, + }), + ); + + cursor = nextMatch.index + nextMatch.token.length; + } + + return <>{parts}; +} + +function createMentionTokens({ + text, + matches, + users, + currentUserId, +}: { + text: string; + matches: MentionMatch[]; + users: Map; + currentUserId?: string; +}): MentionToken[] { + return matches.map((match, index) => { const mentionedUser = Array.from(users.values()).find((u) => { const displayName = u.displayName || ""; return displayName.toLowerCase() === match.username.toLowerCase(); }); const isSelf = mentionedUser?.userId === currentUserId; + const token = createMentionToken(text, index); - parts.push( - - {match.fullMatch} - , - ); + return { + token, + element: ( + + {match.fullMatch} + + ), + }; + }); +} + +function createMentionToken(text: string, index: number): string { + let token = `FIREPITMENTIONTOKEN${index}X`; + while (text.includes(token)) { + token = `${token}X`; + } + return token; +} +function injectMentionTokens( + text: string, + matches: MentionMatch[], + mentionTokens: MentionToken[], +): string { + if (matches.length === 0) { + return text; + } + + const parts: string[] = []; + let lastIndex = 0; + + for (const [index, match] of matches.entries()) { + if (match.startIndex > lastIndex) { + parts.push(text.substring(lastIndex, match.startIndex)); + } + + parts.push(mentionTokens[index]?.token || match.fullMatch); lastIndex = match.endIndex; } if (lastIndex < text.length) { - parts.push( - , - ); + parts.push(text.substring(lastIndex)); } - return {parts}; + return parts.join(""); } /** diff --git a/src/components/search-results.tsx b/src/components/search-results.tsx index 2f4ebfc0..0ade6659 100644 --- a/src/components/search-results.tsx +++ b/src/components/search-results.tsx @@ -158,13 +158,13 @@ export function SearchResults({ )} -

    +

    -

    +
    {message.editedAt && (