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:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+220
@@ -114,6 +114,9 @@ importers:
|
||||
livekit-client:
|
||||
specifier: ^2.9.0
|
||||
version: 2.17.1(@types/dom-mediacapture-record@1.0.22)
|
||||
prism-react-renderer:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -126,6 +129,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.28.0
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
zustand:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.11(@types/react@18.3.28)(react@18.3.1)
|
||||
@@ -1358,6 +1364,9 @@ packages:
|
||||
'@types/plist@3.0.5':
|
||||
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
|
||||
|
||||
'@types/prismjs@1.26.6':
|
||||
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -2225,6 +2234,10 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
escape-string-regexp@5.0.0:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
@@ -2833,6 +2846,9 @@ packages:
|
||||
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
matcher@3.0.0:
|
||||
resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2841,9 +2857,30 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
mdast-util-from-markdown@2.0.2:
|
||||
resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==}
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
|
||||
|
||||
mdast-util-gfm-strikethrough@2.0.0:
|
||||
resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
|
||||
|
||||
mdast-util-gfm-table@2.0.0:
|
||||
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
|
||||
|
||||
mdast-util-gfm-task-list-item@2.0.0:
|
||||
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
|
||||
|
||||
mdast-util-gfm@3.1.0:
|
||||
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
|
||||
|
||||
@@ -2875,6 +2912,27 @@ packages:
|
||||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
|
||||
|
||||
micromark-extension-gfm-footnote@2.1.0:
|
||||
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
|
||||
|
||||
micromark-extension-gfm-strikethrough@2.1.0:
|
||||
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
|
||||
|
||||
micromark-extension-gfm-table@2.1.1:
|
||||
resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
|
||||
|
||||
micromark-extension-gfm-tagfilter@2.0.0:
|
||||
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
|
||||
|
||||
micromark-extension-gfm-task-list-item@2.1.0:
|
||||
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
|
||||
|
||||
micromark-extension-gfm@3.0.0:
|
||||
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
||||
|
||||
@@ -3272,6 +3330,11 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prism-react-renderer@2.4.1:
|
||||
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||
peerDependencies:
|
||||
react: '>=16.0.0'
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@@ -3393,12 +3456,18 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
remark-parse@11.0.0:
|
||||
resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
|
||||
|
||||
remark-rehype@11.1.2:
|
||||
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
|
||||
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5024,6 +5093,8 @@ snapshots:
|
||||
xmlbuilder: 15.1.1
|
||||
optional: true
|
||||
|
||||
'@types/prismjs@1.26.6': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
||||
@@ -6035,6 +6106,8 @@ snapshots:
|
||||
escape-string-regexp@4.0.0:
|
||||
optional: true
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
@@ -6741,6 +6814,8 @@ snapshots:
|
||||
|
||||
map-obj@5.0.0: {}
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
matcher@3.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 4.0.0
|
||||
@@ -6748,6 +6823,13 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
escape-string-regexp: 5.0.0
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
mdast-util-from-markdown@2.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -6765,6 +6847,63 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
ccount: 2.0.1
|
||||
devlop: 1.1.0
|
||||
mdast-util-find-and-replace: 3.0.2
|
||||
micromark-util-character: 2.1.1
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-strikethrough@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-table@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
markdown-table: 3.0.4
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-task-list-item@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm@3.1.0:
|
||||
dependencies:
|
||||
mdast-util-from-markdown: 2.0.2
|
||||
mdast-util-gfm-autolink-literal: 2.0.1
|
||||
mdast-util-gfm-footnote: 2.1.0
|
||||
mdast-util-gfm-strikethrough: 2.0.0
|
||||
mdast-util-gfm-table: 2.0.0
|
||||
mdast-util-gfm-task-list-item: 2.0.0
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-mdx-expression@2.0.1:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
@@ -6860,6 +6999,64 @@ snapshots:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-footnote@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-strikethrough@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-classify-character: 2.0.1
|
||||
micromark-util-resolve-all: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-table@2.1.1:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-tagfilter@2.0.0:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm-task-list-item@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-extension-gfm@3.0.0:
|
||||
dependencies:
|
||||
micromark-extension-gfm-autolink-literal: 2.1.0
|
||||
micromark-extension-gfm-footnote: 2.1.0
|
||||
micromark-extension-gfm-strikethrough: 2.1.0
|
||||
micromark-extension-gfm-table: 2.1.1
|
||||
micromark-extension-gfm-tagfilter: 2.0.0
|
||||
micromark-extension-gfm-task-list-item: 2.1.0
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
micromark-util-types: 2.0.2
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
@@ -7307,6 +7504,12 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prism-react-renderer@2.4.1(react@18.3.1):
|
||||
dependencies:
|
||||
'@types/prismjs': 1.26.6
|
||||
clsx: 2.1.1
|
||||
react: 18.3.1
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@3.0.0: {}
|
||||
@@ -7436,6 +7639,17 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-gfm: 3.1.0
|
||||
micromark-extension-gfm: 3.0.0
|
||||
remark-parse: 11.0.0
|
||||
remark-stringify: 11.0.0
|
||||
unified: 11.0.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
remark-parse@11.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -7453,6 +7667,12 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
|
||||
remark-stringify@11.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-to-markdown: 2.1.2
|
||||
unified: 11.0.5
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
Reference in New Issue
Block a user