feat: rich markdown renderer with @mention badges and autocomplete

- Add MarkdownRenderer with syntax-highlighted code blocks (prism-react-renderer),
  GFM tables, and Discord-style theming
- Add MentionBadge that resolves user IDs to display names with role colors
- Add MentionPopover autocomplete triggered by @ in MessageInput
- Fix mention:// URL sanitization — allowlist the scheme in urlTransform so
  react-markdown v9 passes it through to the component override
- Highlight messages that mention the current user (amber border)
- Bump rate limit from 60 to 200 req/min
This commit is contained in:
Jannis Braun
2026-02-25 23:49:41 +01:00
parent 0a157de162
commit c9f9787b99
8 changed files with 730 additions and 32 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ async function main(): Promise<void> {
});
await app.register(rateLimit, {
max: 60,
max: 200,
timeWindow: '1 minute',
keyGenerator: (request) => (request as any).userId || request.ip,
});
+2
View File
@@ -13,10 +13,12 @@
"@opencord/shared": "workspace:*",
"@sapphi-red/web-noise-suppressor": "^0.3.5",
"livekit-client": "^2.9.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.2"
},
"devDependencies": {
@@ -0,0 +1,227 @@
import React from 'react';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Highlight, themes } from 'prism-react-renderer';
import type { Components } from 'react-markdown';
import { MentionBadge } from './MentionBadge';
// ─── Remark Plugin: Tag Bare Fenced Blocks ─────────────────────────────────
// react-markdown v9 removed the `inline` prop from <code>. Fenced blocks
// without a language hint have no className, making them indistinguishable
// from inline code. This plugin assigns `lang: 'text'` to bare fenced blocks
// so that `className="language-text"` is always present on block-level code.
interface MdastNode {
type: string;
lang?: string | null;
children?: MdastNode[];
}
function remarkDefaultCodeLang() {
return (tree: MdastNode) => {
walkTree(tree);
};
}
function walkTree(node: MdastNode) {
if (node.type === 'code' && !node.lang) {
node.lang = 'text';
}
if (node.children) {
for (const child of node.children) {
walkTree(child);
}
}
}
// ─── Pre-processing: Mention Tokens ─────────────────────────────────────────
// remark-parse mangles <@userId> (treats as autolink or escapes the angle
// brackets) before remark plugins can see the text nodes. We solve this by
// converting mention tokens to standard markdown links BEFORE the parser
// runs. The `a` component override then detects the mention:// scheme.
// Code spans and fenced blocks are matched first and preserved as-is.
function preprocessMentions(raw: string): string {
return raw.replace(
/(```[\s\S]*?```|`[^`]+`)|<@([a-zA-Z0-9_-]+)>/g,
(match, codeBlock: string | undefined, userId: string | undefined) => {
if (codeBlock) return codeBlock;
return `[@${userId}](mention://${userId})`;
},
);
}
// ─── URL Transform: Allow mention:// Scheme ─────────────────────────────────
// react-markdown v9's defaultUrlTransform strips URLs with unknown protocols.
// We whitelist mention:// so the `a` component override receives the full href.
function urlTransform(url: string): string {
if (url.startsWith('mention://')) return url;
return defaultUrlTransform(url);
}
// ─── Custom Theme (Discord Dark) ───────────────────────────────────────────
// Based on One Dark, tuned to match Discord's code block aesthetic.
const discordDarkTheme = {
...themes.oneDark,
plain: {
...themes.oneDark.plain,
backgroundColor: '#2b2d31',
color: '#dcddde',
},
};
// ─── Markdown Component Overrides ──────────────────────────────────────────
const REMARK_PLUGINS = [remarkGfm, remarkDefaultCodeLang];
function CodeBlock({ language, code }: { language: string; code: string }) {
// 'text' means a bare fenced block with no language — render without highlighting
if (language === 'text') {
return (
<pre className="mt-1 p-3 bg-[#2b2d31] border border-[#1e1f22]/50 rounded text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto whitespace-pre">
<code>{code}</code>
</pre>
);
}
return (
<Highlight theme={discordDarkTheme} code={code} language={language}>
{({ style, tokens, getLineProps, getTokenProps }) => (
<pre
className="mt-1 rounded border border-[#1e1f22]/50 text-[0.875rem] leading-[1.125rem] font-mono overflow-x-auto"
style={{ ...style, padding: '0.625rem 0.75rem', margin: 0 }}
>
{tokens.map((line, i) => {
const lineProps = getLineProps({ line });
return (
<div key={i} {...lineProps} style={{ ...lineProps.style, minHeight: '1.125rem' }}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
);
})}
</pre>
)}
</Highlight>
);
}
const MemoizedCodeBlock = React.memo(CodeBlock);
function buildComponents(): Components {
return {
// Paragraphs → spans to avoid block nesting issues in chat messages
p: ({ children }) => <span className="block">{children}</span>,
// Links & Mentions
// remark-parse autolinks <@userId> into mailto:@userId before plugins run,
// so we intercept that pattern here instead of using a remark plugin.
a: ({ href, children }) => {
if (href?.startsWith('mention://')) {
return <MentionBadge userId={href.slice('mention://'.length)} />;
}
const mentionMatch = href?.match(/^(?:mailto:)?@([a-zA-Z0-9_-]+)$/);
if (mentionMatch) {
return <MentionBadge userId={mentionMatch[1]!} />;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[#00aff4] hover:underline"
>
{children}
</a>
);
},
// Fenced code blocks (have className from our remark plugin) vs inline code
code: ({ className, children, ...rest }) => {
const match = /language-(\w+)/.exec(className || '');
if (match) {
const code = String(children).replace(/\n$/, '');
return <MemoizedCodeBlock language={match[1]!} code={code} />;
}
// Inline code
return (
<code
className="px-[0.35em] py-[0.15em] bg-[#2b2d31] rounded-[3px] text-[0.875em] font-mono text-[#e8912d]"
{...rest}
>
{children}
</code>
);
},
// Override <pre> to be a minimal wrapper — the CodeBlock handles all styling
pre: ({ children }) => <>{children}</>,
// Bold / Italic / Strikethrough
strong: ({ children }) => <strong className="font-bold text-[#f2f3f5]">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="line-through text-[#a3a6aa]">{children}</del>,
// Blockquotes
blockquote: ({ children }) => (
<blockquote className="pl-3 border-l-[3px] border-[#4e5058] my-0.5">
{children}
</blockquote>
),
// Lists
ul: ({ children }) => <ul className="list-disc pl-6 my-0.5 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-6 my-0.5 space-y-0.5">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
// Headings — Discord renders these with slightly larger/bolder text
h1: ({ children }) => <div className="text-[1.5rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h2: ({ children }) => <div className="text-[1.25rem] font-bold text-[#f2f3f5] mt-2 mb-1">{children}</div>,
h3: ({ children }) => <div className="text-[1.1rem] font-bold text-[#f2f3f5] mt-1 mb-0.5">{children}</div>,
// Horizontal rules
hr: () => <hr className="border-[#3f4147] my-2" />,
// Images (in markdown content — not attachments)
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ''}
className="max-w-full max-h-[350px] rounded-md mt-1"
loading="lazy"
/>
),
// Tables (GFM)
table: ({ children }) => (
<div className="overflow-x-auto my-1">
<table className="border-collapse text-[0.875rem]">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-[#3f4147]">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => <tr className="border-b border-[#3f4147]/50">{children}</tr>,
th: ({ children }) => <th className="px-3 py-1.5 text-left text-[#f2f3f5] font-semibold">{children}</th>,
td: ({ children }) => <td className="px-3 py-1.5">{children}</td>,
};
}
// Build once and reuse — the components object is static
const MARKDOWN_COMPONENTS = buildComponents();
// ─── Public Component ──────────────────────────────────────────────────────
interface MarkdownRendererProps {
content: string;
}
export const MarkdownRenderer = React.memo(function MarkdownRenderer({ content }: MarkdownRendererProps) {
return (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS} urlTransform={urlTransform}>
{preprocessMentions(content)}
</ReactMarkdown>
);
});
@@ -0,0 +1,59 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
interface MentionBadgeProps {
userId: string;
}
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const member = members.find((m) => m.userId === userId);
const server = servers.find((s) => s.id === currentServerId);
const ownerId = server?.ownerId;
let displayName: string;
let color: string;
if (member) {
displayName = member.user.displayName ?? member.user.username;
if (member.roles && member.roles.length > 0) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
color = sorted[0]!.color;
} else if (ownerId && userId === ownerId) {
color = '#f23f43';
} else {
color = '#5865f2'; // blurple default
}
} else {
displayName = 'Unknown User';
color = '#a3a6aa'; // muted fallback
}
const handleClick = (e: React.MouseEvent) => {
if (!member) return;
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(member.user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.right + 8,
});
};
// Build inline styles: role-colored text with tinted background
const bgColor = color + '1a'; // ~10% opacity hex
return (
<span
onClick={handleClick}
className="inline-flex items-center rounded-[3px] px-[2px] font-medium cursor-pointer transition-colors hover:brightness-125"
style={{ color, backgroundColor: bgColor }}
>
@{displayName}
</span>
);
});
@@ -0,0 +1,92 @@
import React, { useMemo, useRef, useEffect } from 'react';
import type { MemberWithUser } from '@opencord/shared';
import { Avatar } from '../ui/Avatar';
import { useServerStore } from '../../stores/serverStore';
const MAX_RESULTS = 8;
interface MentionPopoverProps {
query: string;
selectedIndex: number;
onSelect: (member: MemberWithUser) => void;
}
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const selectedRef = useRef<HTMLDivElement>(null);
const ownerId = servers.find((s) => s.id === currentServerId)?.ownerId;
const filtered = useMemo(() => {
const q = query.toLowerCase();
return members
.filter((m) => {
const name = (m.user.displayName ?? m.user.username).toLowerCase();
const username = m.user.username.toLowerCase();
return name.includes(q) || username.includes(q);
})
.slice(0, MAX_RESULTS);
}, [members, query]);
// Scroll selected item into view
useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (filtered.length === 0) return null;
const getMemberColor = (member: MemberWithUser): string | undefined => {
if (member.roles && member.roles.length > 0) {
const sorted = [...member.roles].sort((a, b) => b.position - a.position);
return sorted[0]!.color;
}
if (ownerId && member.userId === ownerId) return '#f23f43';
return undefined;
};
return (
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
<div className="bg-[#2b2d31] rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-discord-text-muted uppercase tracking-wider">
Members
</div>
{filtered.map((member, i) => {
const displayName = member.user.displayName ?? member.user.username;
const roleColor = getMemberColor(member);
const isSelected = i === selectedIndex;
return (
<div
key={member.userId}
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelect(member)}
className={`flex items-center gap-2.5 px-2 py-1.5 mx-1 rounded cursor-pointer transition-colors ${
isSelected ? 'bg-[#404249]' : 'hover:bg-[#35373c]'
}`}
>
<Avatar
src={member.user.avatar}
name={displayName}
size={24}
status={member.user.status}
/>
<span
className="text-[14px] font-medium truncate"
style={roleColor ? { color: roleColor } : undefined}
>
{displayName}
</span>
{member.user.displayName && (
<span className="text-[12px] text-discord-text-muted truncate">
@{member.user.username}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
+10 -26
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import type { MessageWithUser } from '@opencord/shared';
import { MarkdownRenderer } from './MarkdownRenderer';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
@@ -141,9 +141,16 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const replyRoleColor = (msg: { userId: string }) => getMemberDisplayColor(msg.userId);
// Self-mention highlighting
const isMentioned = currentUser && message.content?.includes('<@' + currentUser.id + '>');
const content = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
className={`group relative flex px-4 py-0.5 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''} ${
isMentioned
? 'bg-[#3c3829] border-l-2 border-l-[#f0b132] hover:bg-[#45402f]'
: 'hover:bg-discord-modifier-hover'
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
@@ -227,30 +234,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="flex flex-col gap-1">
{message.content && (
<div className="text-discord-text-normal text-[16px] leading-[1.375rem] break-words whitespace-pre-wrap selection:bg-discord-blurple/30">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-discord-text-link hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-[14px] font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary border border-discord-bg-tertiary/50 rounded-md text-[14px] font-mono overflow-x-auto">
{children}
</pre>
),
strong: ({ children }) => <strong className="font-bold text-discord-text-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
<MarkdownRenderer content={message.content} />
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1 select-none font-medium">(edited)</span>
)}
@@ -1,25 +1,48 @@
import React, { useState, useRef, useCallback } from 'react';
import React, { useState, useRef, useCallback, useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel } from '../../stores/serverStore';
import { isDmChannel, useServerStore } from '../../stores/serverStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
import { MentionPopover } from './MentionPopover';
import type { MemberWithUser } from '@opencord/shared';
interface MessageInputProps {
channelId: string;
channelName: string;
}
interface MentionState {
query: string;
startIndex: number;
selectedIndex: number;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [content, setContent] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useServerStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Filter members for the mention popover
const filteredMembers = useMemo(() => {
if (!mentionState) return [];
const q = mentionState.query.toLowerCase();
return members
.filter((m) => {
const name = (m.user.displayName ?? m.user.username).toLowerCase();
const username = m.user.username.toLowerCase();
return name.includes(q) || username.includes(q);
})
.slice(0, 8);
}, [members, mentionState]);
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
const isDm = isDmChannel(channelId);
@@ -38,6 +61,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
if (!trimmed && files.length === 0) return;
setIsUploading(true);
setMentionState(null);
try {
// Upload files first
const attachmentIds: string[] = [];
@@ -50,6 +74,11 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
setContent('');
setFiles([]);
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
@@ -62,7 +91,59 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
}
};
const selectMention = useCallback((member: MemberWithUser) => {
if (!mentionState) return;
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? content.length;
const before = content.slice(0, mentionState.startIndex);
const after = content.slice(cursorPos);
const insertion = `<@${member.userId}> `;
const newContent = before + insertion + after;
setContent(newContent);
setMentionState(null);
// Restore cursor position after React re-renders
const newCursorPos = before.length + insertion.length;
requestAnimationFrame(() => {
if (textarea) {
textarea.focus();
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
}
});
}, [mentionState, content]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention popover keyboard navigation
if (mentionState && filteredMembers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null
);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionState((prev) =>
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null
);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = filteredMembers[mentionState.selectedIndex];
if (selected) selectMention(selected);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionState(null);
return;
}
}
// Default: Enter to submit
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
@@ -101,7 +182,31 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
const value = e.target.value;
const cursorPos = e.target.selectionStart;
setContent(value);
// Detect @mention trigger
const textBeforeCursor = value.slice(0, cursorPos);
const mentionMatch = textBeforeCursor.match(/@([^\s<]*)$/);
if (mentionMatch) {
const atIndex = cursorPos - mentionMatch[0].length;
// Only trigger at word boundary: start of input, after space, or after newline
const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined;
if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') {
setMentionState({
query: mentionMatch[1]!,
startIndex: atIndex,
selectedIndex: 0,
});
} else {
setMentionState(null);
}
} else {
setMentionState(null);
}
handleTyping();
// Auto-resize textarea
@@ -118,7 +223,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
<span className="opacity-60">Replying to</span>
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
</div>
<button
<button
onClick={() => setReplyTo(null)}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
>
@@ -129,10 +234,19 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</div>
)}
<div
className={`bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`}
className={`relative bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-visible`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Mention autocomplete popover */}
{mentionState && filteredMembers.length > 0 && (
<MentionPopover
query={mentionState.query}
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
/>
)}
{/* File previews */}
{files.length > 0 && (
<div className="p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30">