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 && (