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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user