Unify markdown on one engine (Milkdown Crepe)
Replace the two-engine setup (MDXEditor for editing + react-markdown for display) with a single Crepe-based component used for both. Editing is inline WYSIWYG with full GFM that actually works — task lists, tables, and code blocks via type-to-create / the slash menu — and read-only mode renders the exact same way, so write and read can no longer drift (the root cause of the heading/list/table/checkbox bugs). - markdown-editor.tsx: Crepe wrapper supporting edit + readOnly, themed to the glass card via --crepe-* variable overrides (scoped to .milkdown so they win over frame-dark's own definitions). - text-particle-view.tsx: render the message card with the read-only editor; drop the react-markdown component map. - Remove now-unused deps: @mdxeditor/editor, react-markdown, remark-gfm, rehype-highlight, highlight.js. The compose immersive mode (short, centered plain text) is unchanged. https://claude.ai/code/session_019DU6V5z6Nr4Vu7b1fBDnq4
This commit is contained in:
@@ -58,7 +58,7 @@
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"@mdxeditor/editor": "^4.0.1",
|
||||
"@milkdown/crepe": "^7.21.1",
|
||||
"@sentry/electron": "^7.11.0",
|
||||
"@sentry/react": "^10.54.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
@@ -66,7 +66,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"firebase": "^12.10.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"livekit-client": "^2.18.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -74,11 +73,8 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"react-use": "^17.6.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -1,155 +1,49 @@
|
||||
/* Blend MDXEditor into the compose glass card, and restore prose typography.
|
||||
*
|
||||
* MDXEditor renders real <h1>/<ul>/<blockquote> elements and leans on the
|
||||
* browser's default styles for their look — it ships no heading/list
|
||||
* typography of its own. Tailwind's Preflight resets those defaults
|
||||
* (headings -> `font-size: inherit`, lists -> `list-style: none`), which would
|
||||
* otherwise make a heading or bullet look identical to body text. So we style
|
||||
* the content elements explicitly here, mirroring the rendered display in
|
||||
* text-particle-view.tsx so "write" and "read" match. */
|
||||
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
||||
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
||||
* properties; we override those to white-on-transparent with a blue accent so
|
||||
* the editor (and the read-only display, which uses the same engine) sits on
|
||||
* top of the existing card instead of painting its own opaque background. */
|
||||
|
||||
.llink-mdxeditor {
|
||||
--baseBg: transparent;
|
||||
--basePageBg: transparent;
|
||||
/* Override the palette on `.milkdown` itself — frame-dark defines these vars
|
||||
* there, so a lower-specificity ancestor selector would be ignored. */
|
||||
.llink-crepe .milkdown {
|
||||
--crepe-color-background: transparent;
|
||||
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
||||
--crepe-color-surface: rgb(255 255 255 / 0.06);
|
||||
--crepe-color-surface-low: rgb(255 255 255 / 0.1);
|
||||
--crepe-color-on-surface: #ffffff;
|
||||
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.6);
|
||||
--crepe-color-outline: rgb(255 255 255 / 0.25);
|
||||
--crepe-color-primary: #60a5fa;
|
||||
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
||||
--crepe-color-on-secondary: #ffffff;
|
||||
--crepe-color-inverse: #ffffff;
|
||||
--crepe-color-on-inverse: #0b0b0b;
|
||||
--crepe-color-inline-code: #fca5a5;
|
||||
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
||||
--crepe-color-error: #f87171;
|
||||
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
||||
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.llink-mdxeditor [class*="_editorRoot_"] {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.llink-mdx-content {
|
||||
padding: 0 !important;
|
||||
.llink-crepe .milkdown .ProseMirror {
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Editing context: fill the compose card and scroll internally so a long
|
||||
* message stays inside the card rather than growing the whole overlay. */
|
||||
.llink-crepe--fill,
|
||||
.llink-crepe--fill .milkdown {
|
||||
height: 100%;
|
||||
color: rgb(255 255 255 / 0.92);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.llink-mdx-content h1,
|
||||
.llink-mdx-content h2,
|
||||
.llink-mdx-content h3,
|
||||
.llink-mdx-content h4,
|
||||
.llink-mdx-content h5,
|
||||
.llink-mdx-content h6 {
|
||||
color: #fff;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.llink-mdx-content h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.llink-mdx-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.llink-mdx-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.llink-mdx-content h4 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.llink-mdx-content h5 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.llink-mdx-content h6 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
.llink-crepe--fill .milkdown {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Block spacing */
|
||||
.llink-mdx-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
.llink-mdx-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline emphasis */
|
||||
.llink-mdx-content strong {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.llink-mdx-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
.llink-mdx-content a {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Lists — Preflight strips the marker and indentation, so restore both */
|
||||
.llink-mdx-content ul,
|
||||
.llink-mdx-content ol {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.llink-mdx-content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.llink-mdx-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.llink-mdx-content li {
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
/* Task-list items draw their own checkbox — no disc bullet. */
|
||||
.llink-mdx-content li[class*="_listItem"] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.llink-mdx-content blockquote {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgb(255 255 255 / 0.3);
|
||||
font-style: italic;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.llink-mdx-content code {
|
||||
border-radius: 0.25rem;
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
padding: 0.1rem 0.375rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.llink-mdx-content hr {
|
||||
margin: 1rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.llink-mdx-content table {
|
||||
margin-bottom: 0.75rem;
|
||||
border-collapse: collapse;
|
||||
width: auto;
|
||||
}
|
||||
.llink-mdx-content th,
|
||||
.llink-mdx-content td {
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
.llink-mdx-content th {
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
font-weight: 600;
|
||||
.llink-crepe--fill .milkdown .ProseMirror {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,102 +1,103 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
MDXEditor,
|
||||
type MDXEditorMethods,
|
||||
headingsPlugin,
|
||||
listsPlugin,
|
||||
quotePlugin,
|
||||
thematicBreakPlugin,
|
||||
linkPlugin,
|
||||
codeBlockPlugin,
|
||||
codeMirrorPlugin,
|
||||
tablePlugin,
|
||||
markdownShortcutPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import { Crepe } from "@milkdown/crepe";
|
||||
import "@milkdown/crepe/theme/common/style.css";
|
||||
import "@milkdown/crepe/theme/frame-dark.css";
|
||||
import "./markdown-editor.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Languages offered for fenced code blocks. Kept short — this is a message
|
||||
// composer, not a code editor — but enough to cover what people usually paste.
|
||||
const CODE_BLOCK_LANGUAGES = {
|
||||
"": "Plain text",
|
||||
text: "Plain text",
|
||||
bash: "Shell",
|
||||
json: "JSON",
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
tsx: "TSX",
|
||||
py: "Python",
|
||||
go: "Go",
|
||||
rust: "Rust",
|
||||
css: "CSS",
|
||||
html: "HTML",
|
||||
sql: "SQL",
|
||||
};
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
/** Initial markdown. MDXEditor is the source of truth once mounted; changes
|
||||
* flow out through `onChange`, so this is only read on mount. */
|
||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||
* through `onChange`, so this is only read when the editor is (re)created. */
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onChange?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** Render the same engine read-only, for displaying a message. */
|
||||
readOnly?: boolean;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline WYSIWYG markdown editor (Obsidian/Notion feel): headings, lists,
|
||||
* emphasis, links, and code blocks render as you type via MDXEditor's
|
||||
* markdown shortcuts — no separate preview pane. Reads and writes plain
|
||||
* markdown, matching how particle content is stored.
|
||||
* Single markdown engine used for both composing and displaying messages
|
||||
* (Milkdown Crepe). Editing is inline/WYSIWYG (Obsidian/Notion feel) with full
|
||||
* GFM — headings, lists, task lists, tables, code blocks, quotes, links — and
|
||||
* read-only mode renders the exact same way, so write and read never drift.
|
||||
*/
|
||||
export function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
readOnly = false,
|
||||
autoFocus = true,
|
||||
className,
|
||||
}: MarkdownEditorProps) {
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
// Keep the latest onChange without forcing the editor to be recreated.
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus) return;
|
||||
// Defer to the next tick so the editor is mounted before we focus it.
|
||||
const t = setTimeout(() => ref.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, [autoFocus]);
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
let cancelled = false;
|
||||
let created: Crepe | null = null;
|
||||
|
||||
const crepe = new Crepe({
|
||||
root,
|
||||
defaultValue: value,
|
||||
features: {
|
||||
[Crepe.Feature.CodeMirror]: true,
|
||||
[Crepe.Feature.ListItem]: true,
|
||||
[Crepe.Feature.Table]: true,
|
||||
[Crepe.Feature.LinkTooltip]: !readOnly,
|
||||
[Crepe.Feature.Cursor]: !readOnly,
|
||||
[Crepe.Feature.BlockEdit]: !readOnly,
|
||||
[Crepe.Feature.Toolbar]: !readOnly,
|
||||
[Crepe.Feature.Placeholder]: !readOnly,
|
||||
[Crepe.Feature.ImageBlock]: false,
|
||||
[Crepe.Feature.Latex]: false,
|
||||
[Crepe.Feature.TopBar]: false,
|
||||
[Crepe.Feature.AI]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.Placeholder]: { text: placeholder ?? "" },
|
||||
},
|
||||
});
|
||||
|
||||
crepe.setReadonly(readOnly);
|
||||
|
||||
if (!readOnly) {
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown) => {
|
||||
onChangeRef.current?.(markdown);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
crepe.create().then(() => {
|
||||
if (cancelled) {
|
||||
crepe.destroy();
|
||||
return;
|
||||
}
|
||||
created = crepe;
|
||||
if (autoFocus && !readOnly) {
|
||||
root.querySelector<HTMLElement>(".ProseMirror")?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
created?.destroy();
|
||||
};
|
||||
// `value` is the initial content only; recreate when the mode flips.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [readOnly]);
|
||||
|
||||
return (
|
||||
<MDXEditor
|
||||
ref={ref}
|
||||
markdown={value}
|
||||
onChange={onChange}
|
||||
// Defensive: core handles HTML and degrades unknown syntax to text, so
|
||||
// this is rare — surface it rather than failing silently.
|
||||
onError={({ source, error }) =>
|
||||
console.warn("MarkdownEditor parse issue:", error, source)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
contentEditableClassName="llink-mdx-content"
|
||||
className={cn("dark-theme llink-mdxeditor", className)}
|
||||
plugins={[
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
quotePlugin(),
|
||||
thematicBreakPlugin(),
|
||||
// disableAutoLink: keep pasted/typed URLs as plain text rather than
|
||||
// turning them into `[url](url)`. The compose flow already turns bare
|
||||
// URLs into link-preview cards, so auto-linking would both duplicate
|
||||
// the URL in the markdown and double up the preview cards.
|
||||
linkPlugin({ disableAutoLink: true }),
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
|
||||
codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }),
|
||||
tablePlugin(),
|
||||
// Headings, lists (incl. `- [ ]` task lists), emphasis, strikethrough,
|
||||
// inline code, quotes, and links all transform as you type via these
|
||||
// shortcuts; fenced code blocks and tables are inserted via paste/import.
|
||||
markdownShortcutPlugin(),
|
||||
]}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("llink-crepe", !readOnly && "llink-crepe--fill", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ import { ParticleAttachments } from "@/features/particles/particle-attachments";
|
||||
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
|
||||
import { RelativeTimestamp } from "@/components/relative-timestamp";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
import { MarkdownEditor } from "@/features/compose/markdown-editor";
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
|
||||
@@ -60,107 +57,6 @@ function hasMarkdownFormatting(content: string): boolean {
|
||||
return /^#{1,6} |^\s*[-*+] |^\s*\d+\. |^```|`[^`]+`|\*\*|__|\*[^*]|_[^_]|^>/m.test(content);
|
||||
}
|
||||
|
||||
const markdownComponents: React.ComponentProps<typeof ReactMarkdown>["components"] = {
|
||||
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
|
||||
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
|
||||
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
|
||||
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic text-white">{children}</em>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ className, children, ...props }) => {
|
||||
const isBlock = className?.startsWith("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn(className, "text-sm")} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
del: ({ children }) => <del className="text-white/70 line-through">{children}</del>,
|
||||
ul: ({ className, children }) => (
|
||||
<ul
|
||||
className={cn(
|
||||
"mb-3 text-white last:mb-0",
|
||||
className?.includes("contains-task-list") ? "list-none pl-0" : "list-disc pl-5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
|
||||
li: ({ className, children }) => (
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1 leading-relaxed",
|
||||
className?.includes("task-list-item") && "flex list-none items-start gap-2",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
input: ({ type, checked }) =>
|
||||
type === "checkbox" ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
readOnly
|
||||
className="mt-1.5 size-3.5 shrink-0 accent-blue-500"
|
||||
/>
|
||||
) : null,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="mb-3 overflow-x-auto last:mb-0">
|
||||
<table className="w-auto border-collapse text-sm text-white">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-white/20 bg-white/10 px-2.5 py-1.5 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-white/20 px-2.5 py-1.5">{children}</td>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-white/10" />,
|
||||
};
|
||||
|
||||
function MarkdownContent({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn("break-words", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -307,7 +203,7 @@ export function TextParticleView({
|
||||
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
|
||||
)}
|
||||
>
|
||||
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
|
||||
<MarkdownEditor value={content} readOnly className="select-text pb-3" />
|
||||
|
||||
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||
|
||||
|
||||
+729
-919
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user