perf(web): reduce demo bandwidth usage

- Cache fingerprinted frontend assets for one month.
- Load media, rich renderers, dialogs, and editors only when needed.
- Simplify seeded demo content and cover deferred-loading behavior.
This commit is contained in:
johnnyjoygh
2026-07-18 22:06:14 +08:00
parent 0bfeb91d50
commit 88c6ee8ebc
30 changed files with 704 additions and 210 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ var embeddedFiles embed.FS
const (
frontendHTMLCacheControl = "no-cache, no-store, must-revalidate"
frontendStaticAssetCacheControl = "public, max-age=3600"
frontendHashedAssetCacheControl = "public, max-age=3600, immutable"
frontendHashedAssetCacheControl = "public, max-age=2592000, immutable"
)
type FrontendService struct {
+8 -9
View File
@@ -26,7 +26,7 @@ INSERT INTO user (id,username,role,nickname,description,password_hash) VALUES(4,
-- ============================================================================
-- 1. Welcome (steven, pinned) — orientation + SSO call-to-action + protected teaser
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(1,'welcome0000001',1,strftime('%s','now','-2 days'),strftime('%s','now','-2 days'),replace('# Welcome to Memos 👋\n\nHi, I''m Steven — I build Memos. You''ve found the public demo: a real, living instance where you can poke at every feature before self-hosting your own.\n\nMemos is an open-source, self-hosted home for short-form thoughts. Capture quickly, organize lightly, own everything.\n\n> Most apps treat notes like documents. Memos treats them like thoughts — small, timestamped, searchable.\n\n## Make yourself at home\n\n- [ ] Scroll this Explore feed — everything here was written with Memos\n- [ ] React to a memo you like (🎉 works great on this one)\n- [ ] **Sign in** (top right) — SSO takes ten seconds and gives you your own space\n- [ ] Write your first memo. One sentence is plenty. That''s the point.\n- [ ] Like it? Star [the repo](https://github.com/usememos/memos) ⭐\n\n🔒 *Psst — a few memos on this instance are hidden until you sign in. Members only.*\n\n## What Memos can do\n\n| Feature | Where to see it |\n|---------|-----------------|\n| **Markdown** | Everywhere — tables, `code`, task lists, quotes |\n| **Math & diagrams** | KaTeX and Mermaid — see my rendering test memo |\n| **Tags** | `#dev/git` — nested tags grow a tree in the sidebar |\n| **Comments & reactions** | Right under this memo 👇 |\n| **Memo references** | Alice''s weekly review links back to her week |\n| **Attachments** | Ben''s golden-hour art from the coastal trail |\n| **Locations** | Look for the 📍 pins |\n| **Visibility** | Public, protected, or private — chosen per memo |\n\n## Take it home\n\n```bash\ndocker run -d -p 5230:5230 -v ~/.memos:/var/opt/memos neosmemo/memos:stable\n```\n\nYour data lives in one SQLite file, on your machine. #welcome #getting-started','\n',char(10)),'PUBLIC',1,'{"tags":["welcome","getting-started"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(1,'welcome0000001',1,strftime('%s','now','-2 days'),strftime('%s','now','-2 days'),replace('# Welcome to Memos 👋\n\nHi, I''m Steven — I build Memos. You''ve found the public demo: a real, living instance where you can poke at every feature before self-hosting your own.\n\nMemos is an open-source, self-hosted home for short-form thoughts. Capture quickly, organize lightly, own everything.\n\n> Most apps treat notes like documents. Memos treats them like thoughts — small, timestamped, searchable.\n\n## Make yourself at home\n\n- [ ] Scroll this Explore feed — everything here was written with Memos\n- [ ] React to a memo you like (🎉 works great on this one)\n- [ ] **Sign in** (top right) — SSO takes ten seconds and gives you your own space\n- [ ] Write your first memo. One sentence is plenty. That''s the point.\n- [ ] Like it? Star [the repo](https://github.com/usememos/memos) ⭐\n\n🔒 *Psst — a few memos on this instance are hidden until you sign in. Members only.*\n\n## What Memos can do\n\n| Feature | Where to see it |\n|---------|-----------------|\n| **Markdown** | Everywhere — tables, `code`, task lists, quotes |\n| **Tags** | `#dev/git` — nested tags grow a tree in the sidebar |\n| **Comments & reactions** | Right under this memo 👇 |\n| **Memo references** | Alice''s weekly review links back to her week |\n| **Attachments** | Ben''s golden-hour art from the coastal trail |\n| **Locations** | Look for the 📍 pins |\n| **Visibility** | Public, protected, or private — chosen per memo |\n\n## Take it home\n\n```bash\ndocker run -d -p 5230:5230 -v ~/.memos:/var/opt/memos neosmemo/memos:stable\n```\n\nYour data lives in one SQLite file, on your machine. #welcome #getting-started','\n',char(10)),'PUBLIC',1,'{"tags":["welcome","getting-started"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
-- 2. Sponsors (steven, pinned) — sits below Welcome (older created_ts)
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pinned,payload) VALUES(2,'sponsors000001',1,strftime('%s','now','-6 days'),strftime('%s','now','-6 days'),replace('Memos is free and open source, kept independent by its sponsors — no ads, no telemetry, no paywalls. 🙏 If you build with Memos, please consider supporting them in return.\n\n## ⭐ Sponsors\n\n<a href="https://coderabbit.link/usememos" target="_blank" rel="noopener"><img src="https://victorious-bubble-f69a016683.media.strapiapp.com/Orange_Typemark_43bf516c9d.svg" alt="CodeRabbit" height="44" /></a>\n\nCut code review time & bugs in half, instantly. [coderabbit.link/usememos →](https://coderabbit.link/usememos)\n\n---\n\n**SSD Nodes** — Affordable VPS hosting for self-hosters. [ssdnodes.com →](https://ssdnodes.com/?utm_source=memos&utm_medium=sponsor)\n\n---\n\nWant to see your company here? Reach out via [GitHub Sponsors](https://github.com/sponsors/usememos).\n\n#sponsors','\n',char(10)),'PUBLIC',1,'{"tags":["sponsors"],"property":{"hasLink":true}}');
@@ -36,7 +36,10 @@ INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pin
-- and thinning out toward -22 days so the feed reads as a living instance
-- ============================================================================
-- 3. Ben: photo memo with an image attachment + location (newest in the feed)
-- 10. Steven: official Chrome extension launch (featured first in Explore)
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(10,'webclipper0001',1,strftime('%s','now','-30 minutes'),strftime('%s','now','-30 minutes'),replace('# ✂️ Memos Web Clipper for Chrome\n\nThe ***official*** Memos Web Clipper turns the page you''re reading into a source-linked memo in under five seconds.\n\n[Add Memos Web Clipper to Chrome →](https://chromewebstore.google.com/detail/memos-web-clipper/nebaoebnljalfegiidibihhkebeiklbl)\n\n| Capture from Chrome | What lands in Memos |\n|---------------------|---------------------|\n| Current page | Title, summary, and source link |\n| Text selection | Quoted excerpt with its source |\n| Selected image | A real attachment on your instance |\n\nEdit the generated memo, choose its visibility, or customize the template and default tags before saving.\n\n### Private by design\n\nThe clipper sends captured content only to the self-hosted Memos instance you connect — and only when you save. No analytics, third-party servers, or tracking. #memos/extensions','\n',char(10)),'PUBLIC','{"tags":["memos/extensions"],"property":{"hasLink":true}}');
-- 3. Ben: photo memo with an image attachment + location
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(3,'goldenhour0001',3,strftime('%s','now','-3 hours'),strftime('%s','now','-3 hours'),'Golden hour on the coastal trail tonight. The camera couldn''t hold the colors, so here''s my minimal-art rendition instead 🎨 #travel/hikes','PUBLIC','{"tags":["travel/hikes"],"property":{"hasLink":false},"location":{"placeholder":"Point Reyes, California, United States","latitude":37.9954,"longitude":-123.0204}}');
-- 27. Steven: build-in-public changelog note (real v0.30 highlights)
@@ -63,12 +66,8 @@ INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,pay
-- 9. Zoe: recipe with a task list
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(9,'zoeramen000001',4,strftime('%s','now','-4 days'),strftime('%s','now','-4 days'),replace('## 🍜 Midnight miso ramen (15 minutes)\n\nFor nights when the book was too good to stop reading.\n\n- [x] Dashi or veg stock, 500ml\n- [x] Miso paste, 2 big spoons\n- [x] Noodles, one nest\n- [x] Soft egg — 6½ minutes, exactly\n- [ ] Scallions (always forget the scallions)\n\nSteep the miso, don''t boil it. That''s the whole secret. #cooking','\n',char(10)),'PUBLIC','{"tags":["cooking"],"property":{"hasLink":false,"hasTaskList":true,"hasIncompleteTasks":true}}');
-- 10. Steven: Scratchpad promo
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(10,'scratchpad0001',1,strftime('%s','now','-8 days'),strftime('%s','now','-8 days'),replace('## 🧩 Try Memos Scratchpad\n\nA lightweight visual canvas for quick thoughts, links, images, files, and small cards before they become polished notes.\n\n[Open Scratchpad →](https://usememos.com/scratchpad)\n\n| Need | Scratchpad helps you |\n|------|----------------------|\n| Capture fast | Double-click to add notes |\n| Arrange freely | Drag cards around |\n| Add context | Attach files, links, and images |\n| Move around | Zoom and pan without setup |\n\n### Local-first\n\nYour Scratchpad cards stay in your browser. Memos does not access, sync, or store them.','\n',char(10)),'PUBLIC','{"property":{"hasLink":true}}');
-- 30. Steven: rendering test page — the explicit feature showcase (math, mermaid,
-- syntax highlighting, tables, task lists, text styles) framed as maintainer dogfooding
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(30,'rendertest0001',1,strftime('%s','now','-10 days'),strftime('%s','now','-10 days'),replace('## 🧪 My rendering test memo #memos/dev\n\nI keep this memo around to eyeball the renderer after every release. If anything below looks broken, the next version isn''t shipping.\n\n**Bold**, *italic*, ~~strikethrough~~, `inline code`, and a [link](https://www.usememos.com/docs).\n\n> Blockquotes, naturally.\n\nInline math $e^{i\pi} + 1 = 0$ and block math:\n\n$$\n\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}\n$$\n\n```mermaid\ngraph LR\n A[Capture] --> B[Tag]\n B --> C[Find it later]\n C --> A\n```\n\n```go\nfunc main() {\n fmt.Println("syntax highlighting ✓")\n}\n```\n\n| Render | Status |\n|--------|--------|\n| Tables | ✅ |\n| Everything else | see above |\n\n- [x] KaTeX loads\n- [x] Mermaid draws\n- [ ] Find a bug before users do','\n',char(10)),'PUBLIC','{"tags":["memos/dev"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
-- 30. Steven: compact Markdown rendering check framed as maintainer dogfooding
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(30,'rendertest0001',1,strftime('%s','now','-10 days'),strftime('%s','now','-10 days'),replace('## 🧪 Markdown rendering check #memos/dev\n\nA quick smoke test I open after releases:\n\n**Bold**, *italic*, ~~strikethrough~~, `inline code`, and a [docs link](https://www.usememos.com/docs).\n\n> Simple notes should render beautifully.\n\n```go\nfmt.Println("syntax highlighting ✓")\n```\n\n| Check | Status |\n|-------|--------|\n| Text styles | ✅ |\n| Code blocks | ✅ |\n| Tables | ✅ |\n\n- [x] Check desktop\n- [x] Check mobile\n- [ ] Ship the next release','\n',char(10)),'PUBLIC','{"tags":["memos/dev"],"property":{"hasLink":true,"hasCode":true,"hasTaskList":true,"hasIncompleteTasks":true}}');
-- 11. Zoe: movie watchlist with a table
INSERT INTO memo (id,uid,creator_id,created_ts,updated_ts,content,visibility,payload) VALUES(11,'zoemovies00001',4,strftime('%s','now','-9 days'),strftime('%s','now','-9 days'),replace('## 🎬 Movie catch-up #movies\n\nWorking through films I''ve been meaning to watch for, uh, years.\n\n| Movie | Status | Rating |\n|-------|--------|--------|\n| The Grand Budapest Hotel | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Spirited Away | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Everything Everywhere All at Once | ✅ Watched | ⭐⭐⭐⭐⭐ |\n| Dune: Part Two | 📅 This weekend | — |\n| Oppenheimer | 📋 Queued | — |\n\n**Spirited Away**: cried twice, no regrets. **Everything Everywhere**: went in cold, did not expect the bagel.\n\nNext month: full Miyazaki marathon 🎨 Taking suggestions for where to start.','\n',char(10)),'PUBLIC','{"tags":["movies"],"property":{"hasLink":false}}');
@@ -176,7 +175,7 @@ INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(20,1,'memos
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(21,4,'memos/bennighttrain1','💛');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(22,3,'memos/alicegitcheat1','🔥');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(23,4,'memos/alicegitcheat1','💡');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(24,4,'memos/scratchpad0001','💡');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(24,4,'memos/webclipper0001','💡');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(25,1,'memos/tagtipmemo0001','👍');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(26,2,'memos/protectedintro','👀');
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(27,3,'memos/protectedintro','');
@@ -3,6 +3,7 @@ import { toBlob } from "html-to-image";
const WINDOW_HORIZONTAL_MARGIN = 32;
const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40;
const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48;
const PREVIEW_ASSET_TIMEOUT_MS = 5000;
export const MEMO_SHARE_IMAGE_CONFIG = {
dialogExtraWidth: 80,
@@ -42,13 +43,26 @@ const waitForPreviewAssets = async (node: HTMLElement) => {
images.map(
(image) =>
new Promise<void>((resolve) => {
// Share previews may sit inside a scroll container. Promote their images
// so native lazy loading cannot leave the export waiting indefinitely.
image.loading = "eager";
if (image.complete) {
resolve();
return;
}
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
let timeoutId: number;
const finish = () => {
window.clearTimeout(timeoutId);
image.removeEventListener("load", finish);
image.removeEventListener("error", finish);
resolve();
};
image.addEventListener("load", finish, { once: true });
image.addEventListener("error", finish, { once: true });
timeoutId = window.setTimeout(finish, PREVIEW_ASSET_TIMEOUT_MS);
}),
),
);
+49 -7
View File
@@ -1,6 +1,7 @@
import { LoaderCircleIcon, MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import MemoEditor from "@/components/MemoEditor";
import { type ComponentType, useCallback, useState } from "react";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import MemoView from "@/components/MemoView";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
@@ -21,6 +22,8 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
const t = useTranslate();
const currentUser = useCurrentUser();
const [showEditor, setShowEditor] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
const showCreateButton = currentUser && !showEditor;
@@ -28,6 +31,27 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
setShowEditor(false);
};
const preloadEditor = useCallback(() => {
void loadMemoEditor().catch(() => undefined);
}, []);
const openEditor = useCallback(async () => {
if (isEditorLoading) {
return;
}
setIsEditorLoading(true);
try {
const { default: MemoEditor } = await loadMemoEditor();
setEditorComponent(() => MemoEditor);
setShowEditor(true);
} catch {
// Chunk failures are handled by loadWithReload; keep the current UI mounted.
} finally {
setIsEditorLoading(false);
}
}, [isEditorLoading]);
return (
<div className="pt-8 pb-16 w-full">
<h2 id="comments" className="sr-only">
@@ -37,9 +61,19 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
{comments.length === 0 ? (
showCreateButton && (
<div className="w-full flex flex-row justify-center items-center py-6">
<Button variant="ghost" onClick={() => setShowEditor(true)}>
<Button
variant="ghost"
onPointerEnter={preloadEditor}
onFocus={preloadEditor}
onClick={openEditor}
disabled={isEditorLoading}
>
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
{isEditorLoading ? (
<LoaderCircleIcon className="ml-2 h-5 w-5 animate-spin text-muted-foreground" />
) : (
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
)}
</Button>
</div>
)
@@ -51,15 +85,23 @@ const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFet
<span className="text-muted-foreground text-sm ml-1">({comments.length})</span>
</div>
{showCreateButton && (
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShowEditor(true)}>
<Button
variant="ghost"
className="text-muted-foreground"
onPointerEnter={preloadEditor}
onFocus={preloadEditor}
onClick={openEditor}
disabled={isEditorLoading}
>
{isEditorLoading && <LoaderCircleIcon className="h-4 w-4 animate-spin" />}
{t("memo.comment.write-a-comment")}
</Button>
)}
</div>
)}
{showEditor && (
{showEditor && EditorComponent && (
<div className="w-full mb-2">
<MemoEditor
<EditorComponent
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
+47 -48
View File
@@ -1,10 +1,11 @@
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { escape as escapeHTML } from "lodash-es";
import { CheckIcon, CopyIcon } from "lucide-react";
import { isValidElement, type ReactElement, type ReactNode, useEffect, useMemo, useState } from "react";
import { isValidElement, type ReactElement, type ReactNode, useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { ensureHighlightTheme, highlightCode, isPlainTextLanguage } from "./highlight";
import { MermaidBlock } from "./MermaidBlock";
import type { ReactMarkdownProps } from "./markdown/types";
import { extractCodeContent, extractLanguage } from "./utils";
@@ -15,9 +16,6 @@ interface CodeBlockProps extends ReactMarkdownProps {
}
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const codeElement = isValidElement(children) ? (children as ReactElement<{ className?: string }>) : null;
const codeClassName = codeElement?.props.className || "";
const codeContent = extractCodeContent(children);
@@ -34,55 +32,56 @@ export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBl
);
}
// Keying on the inputs remounts the block when they change, so highlight state
// can never be shown against a different code content.
return <HighlightedCodeBlock key={`${language}\u0000${codeContent}`} codeContent={codeContent} language={language} />;
};
interface HighlightedCodeBlockProps {
codeContent: string;
language: string;
}
const HighlightedCodeBlock = ({ codeContent, language }: HighlightedCodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const [highlightedCode, setHighlightedCode] = useState<string>();
const renderedCode = highlightedCode ?? escapeHTML(codeContent);
const theme = getThemeWithFallback(userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");
// Dynamically load highlight.js theme based on app theme
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [resolvedTheme, isDarkTheme]);
// Highlight code using highlight.js
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(language);
if (lang) {
return hljs.highlight(codeContent, {
language: language,
}).value;
}
} catch {
// Skip error and use default highlighted code.
if (isPlainTextLanguage(language)) {
return;
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: codeContent,
}).innerHTML;
}, [language, codeContent]);
void ensureHighlightTheme(isDarkTheme);
}, [isDarkTheme, language]);
useEffect(() => {
if (isPlainTextLanguage(language)) {
// The escaped fallback already is the final output; skip the async round-trip.
return;
}
let cancelled = false;
void highlightCode(codeContent, language)
.then((value) => {
if (!cancelled) {
setHighlightedCode(value);
}
})
.catch(() => {
// Keep the escaped plain-text fallback when a language chunk cannot load.
});
return () => {
cancelled = true;
};
}, [codeContent, language]);
const handleCopy = async () => {
try {
@@ -148,7 +147,7 @@ export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBl
<div className="overflow-x-auto">
<code
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
dangerouslySetInnerHTML={{ __html: renderedCode }}
/>
</div>
</pre>
@@ -0,0 +1,14 @@
import "katex/dist/katex.min.css";
import rehypeKatex from "rehype-katex";
import remarkMath from "remark-math";
import { MemoMarkdownRendererCore, type MemoMarkdownRendererProps } from "./MemoMarkdownRenderer";
const MathMarkdownRenderer = (props: MemoMarkdownRendererProps) => (
<MemoMarkdownRendererCore
{...props}
mathRemarkPlugins={[remarkMath]}
mathRehypePlugins={[[rehypeKatex, { throwOnError: false, strict: false }]]}
/>
);
export default MathMarkdownRenderer;
@@ -1,14 +1,13 @@
import type { Element } from "hast";
import "katex/dist/katex.min.css";
import { type ComponentProps, type ReactNode, Suspense } from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
import { lazyWithReload } from "@/utils/lazy";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
@@ -20,12 +19,13 @@ import { SANITIZE_SCHEMA } from "./constants";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
import { AnchorLink, Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { hasMathSyntax } from "./math";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { TrustedIframe } from "./TrustedIframe";
interface MemoMarkdownRendererProps {
export interface MemoMarkdownRendererProps {
content: string;
resolvedMentionUsernames: Set<string>;
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
@@ -34,7 +34,18 @@ interface MemoMarkdownRendererProps {
compact?: boolean;
}
function getMentionUsername(node: Element, children?: React.ReactNode): string {
type RemarkPlugins = NonNullable<ComponentProps<typeof ReactMarkdown>["remarkPlugins"]>;
type RehypePlugins = NonNullable<ComponentProps<typeof ReactMarkdown>["rehypePlugins"]>;
interface MemoMarkdownRendererCoreProps extends MemoMarkdownRendererProps {
/** Math plugins injected by MathMarkdownRenderer; the remark ones must run before remarkGfm. */
mathRemarkPlugins?: RemarkPlugins;
mathRehypePlugins?: RehypePlugins;
}
const MathMarkdownRenderer = lazyWithReload(() => import("./MathMarkdownRenderer"));
function getMentionUsername(node: Element, children?: ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
@@ -53,7 +64,14 @@ function getMentionUsername(node: Element, children?: React.ReactNode): string {
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
export const MemoMarkdownRendererCore = ({
content,
resolvedMentionUsernames,
memoName,
compact,
mathRemarkPlugins = [],
mathRehypePlugins = [],
}: MemoMarkdownRendererCoreProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
@@ -144,7 +162,7 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
...mathRemarkPlugins,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
@@ -152,12 +170,7 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeHeadingId, ...mathRehypePlugins]}
components={markdownComponents}
>
{content}
@@ -165,3 +178,15 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoNa
</MarkdownRenderContext.Provider>
);
};
export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
if (!hasMathSyntax(props.content)) {
return <MemoMarkdownRendererCore {...props} />;
}
return (
<Suspense fallback={<MemoMarkdownRendererCore {...props} />}>
<MathMarkdownRenderer {...props} />
</Suspense>
);
};
+137
View File
@@ -0,0 +1,137 @@
import type { HLJSApi, LanguageFn } from "highlight.js";
import { escape as escapeHTML } from "lodash-es";
type LanguageModule = { default: LanguageFn };
type LanguageLoader = () => Promise<LanguageModule>;
// Keep the languages most commonly used in memos cheap. Less common languages
// fall back to the complete build, preserving existing language support without
// making every visitor download all language definitions.
const commonLanguageLoaders = {
bash: () => import("highlight.js/lib/languages/bash"),
c: () => import("highlight.js/lib/languages/c"),
cpp: () => import("highlight.js/lib/languages/cpp"),
csharp: () => import("highlight.js/lib/languages/csharp"),
css: () => import("highlight.js/lib/languages/css"),
diff: () => import("highlight.js/lib/languages/diff"),
dockerfile: () => import("highlight.js/lib/languages/dockerfile"),
go: () => import("highlight.js/lib/languages/go"),
graphql: () => import("highlight.js/lib/languages/graphql"),
java: () => import("highlight.js/lib/languages/java"),
javascript: () => import("highlight.js/lib/languages/javascript"),
json: () => import("highlight.js/lib/languages/json"),
kotlin: () => import("highlight.js/lib/languages/kotlin"),
markdown: () => import("highlight.js/lib/languages/markdown"),
php: () => import("highlight.js/lib/languages/php"),
python: () => import("highlight.js/lib/languages/python"),
ruby: () => import("highlight.js/lib/languages/ruby"),
rust: () => import("highlight.js/lib/languages/rust"),
sql: () => import("highlight.js/lib/languages/sql"),
swift: () => import("highlight.js/lib/languages/swift"),
typescript: () => import("highlight.js/lib/languages/typescript"),
xml: () => import("highlight.js/lib/languages/xml"),
yaml: () => import("highlight.js/lib/languages/yaml"),
} satisfies Record<string, LanguageLoader>;
type CommonLanguage = keyof typeof commonLanguageLoaders;
const languageAliases: Record<string, CommonLanguage> = {
cs: "csharp",
cxx: "cpp",
golang: "go",
htm: "xml",
html: "xml",
js: "javascript",
jsx: "javascript",
md: "markdown",
py: "python",
rb: "ruby",
sh: "bash",
shell: "bash",
ts: "typescript",
tsx: "typescript",
vue: "xml",
yml: "yaml",
zsh: "bash",
};
const plainTextLanguages = new Set(["", "plain", "plaintext", "text", "txt"]);
const registeredLanguagePromises = new Map<CommonLanguage, Promise<HLJSApi>>();
let corePromise: Promise<HLJSApi> | undefined;
let fullBuildPromise: Promise<HLJSApi> | undefined;
let injectedThemeKey: string | undefined;
export const isPlainTextLanguage = (language: string): boolean => plainTextLanguages.has(language.trim().toLowerCase());
// The hljs theme stylesheet is a document-global singleton, so it is managed here
// (keyed by theme) rather than by each rendered code block.
export const ensureHighlightTheme = async (isDarkTheme: boolean): Promise<void> => {
const themeKey = isDarkTheme ? "dark" : "light";
if (injectedThemeKey === themeKey) {
return;
}
injectedThemeKey = themeKey;
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
if (injectedThemeKey !== themeKey) {
return;
}
document.querySelector("style[data-hljs-theme]")?.remove();
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", themeKey);
document.head.appendChild(style);
} catch (error) {
injectedThemeKey = undefined;
console.warn("Failed to load highlight.js theme:", error);
}
};
const loadCore = (): Promise<HLJSApi> => {
corePromise ??= import("highlight.js/lib/core").then((module) => module.default);
return corePromise;
};
const loadCommonLanguage = (language: CommonLanguage): Promise<HLJSApi> => {
const existingPromise = registeredLanguagePromises.get(language);
if (existingPromise) {
return existingPromise;
}
const languagePromise = Promise.all([loadCore(), commonLanguageLoaders[language]()]).then(([hljs, languageModule]) => {
if (!hljs.getLanguage(language)) {
hljs.registerLanguage(language, languageModule.default);
}
return hljs;
});
registeredLanguagePromises.set(language, languagePromise);
return languagePromise;
};
const loadFullBuild = (): Promise<HLJSApi> => {
fullBuildPromise ??= import("highlight.js").then((module) => module.default);
return fullBuildPromise;
};
export const highlightCode = async (code: string, language: string): Promise<string> => {
const normalizedLanguage = language.trim().toLowerCase();
if (isPlainTextLanguage(normalizedLanguage)) {
return escapeHTML(code);
}
const canonicalLanguage =
languageAliases[normalizedLanguage] ??
(Object.hasOwn(commonLanguageLoaders, normalizedLanguage) ? (normalizedLanguage as CommonLanguage) : undefined);
const hljs = canonicalLanguage ? await loadCommonLanguage(canonicalLanguage) : await loadFullBuild();
const languageToHighlight = canonicalLanguage ?? normalizedLanguage;
if (!hljs.getLanguage(languageToHighlight)) {
return escapeHTML(code);
}
return hljs.highlight(code, { language: languageToHighlight }).value;
};
@@ -14,6 +14,8 @@ export const Image = ({ className, alt, node: _node, height, width, style, ...pr
alt={alt}
style={{ height: height ? `${height}px` : undefined, width: width ? `${width}px` : undefined, ...style }}
{...props}
loading="lazy"
decoding="async"
/>
);
};
+11
View File
@@ -0,0 +1,11 @@
const stripCode = (content: string): string =>
content
.replace(/```[\s\S]*?```/g, "")
.replace(/~~~[\s\S]*?~~~/g, "")
.replace(/`(?:\\.|[^`\n])*`/g, "");
// This is only a loading heuristic; remark-math remains the source of truth for
// parsing. Any unescaped dollar outside code triggers the math chunk: a false
// positive merely loads it, whereas mirroring remark-math's grammar here risks
// false negatives that would silently render real math as raw dollars.
export const hasMathSyntax = (content: string): boolean => /(^|[^\\])\$/.test(stripCode(content));
+15
View File
@@ -0,0 +1,15 @@
import { loadWithReload } from "@/utils/lazy";
type MemoEditorModule = typeof import(".");
let memoEditorModulePromise: Promise<MemoEditorModule> | undefined;
// Share one in-flight request across edit and comment entry points. Callers
// keep their current UI mounted until this promise resolves.
export const loadMemoEditor = (): Promise<MemoEditorModule> => {
memoEditorModulePromise ??= loadWithReload(() => import(".")).catch((error) => {
memoEditorModulePromise = undefined;
throw error;
});
return memoEditorModulePromise;
};
@@ -32,11 +32,11 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
}
if (attachmentType === "video/*") {
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="none" />;
}
if (attachmentType === "audio/*") {
return <audio src={sourceUrl} className={cn("w-full rounded-lg", className)} controls preload="metadata" />;
return <audio src={sourceUrl} className={cn("w-full rounded-lg", className)} controls preload="none" />;
}
return null;
@@ -10,7 +10,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import { useTranslate } from "@/utils/i18n";
import type { PreviewMediaItem } from "@/utils/media-item";
import { formatAudioTime } from "./attachmentHelpers";
import { formatAudioTime, toggleAudioPlayback } from "./attachmentHelpers";
interface AttachmentListEditorProps {
attachments: Attachment[];
@@ -119,16 +119,7 @@ const AttachmentItemCard: FC<{
return;
}
if (audio.paused) {
try {
await audio.play();
} catch {
setIsPlaying(false);
}
return;
}
audio.pause();
await toggleAudioPlayback(audio, sourceUrl, () => setIsPlaying(false));
};
return (
@@ -144,7 +135,7 @@ const AttachmentItemCard: FC<{
className={cn("h-full w-full overflow-hidden", isPreviewable ? "cursor-pointer" : "cursor-default")}
aria-label={`Preview ${filename}`}
>
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" />
<img src={thumbnailUrl} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" />
</div>
) : isVoiceNote ? (
<>
@@ -158,8 +149,7 @@ const AttachmentItemCard: FC<{
</button>
<audio
ref={audioRef}
src={sourceUrl}
preload="metadata"
preload="none"
className="hidden"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import { formatAudioTime } from "./attachmentHelpers";
import { formatAudioTime, toggleAudioPlayback } from "./attachmentHelpers";
const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const;
const UNKNOWN_DURATION_LABEL = "--:--";
@@ -91,16 +91,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compa
return;
}
if (audio.paused) {
try {
await audio.play();
} catch {
setIsPlaying(false);
}
return;
}
audio.pause();
await toggleAudioPlayback(audio, sourceUrl, () => setIsPlaying(false));
};
const handleSeek = (nextTime: number) => {
@@ -171,8 +162,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compa
<audio
ref={audioRef}
src={sourceUrl}
preload="metadata"
preload="none"
className="hidden"
onLoadedMetadata={(e) => handleDuration(e.currentTarget.duration)}
onDurationChange={(e) => handleDuration(e.currentTarget.duration)}
@@ -43,6 +43,26 @@ export const getAttachmentMetadata = (attachment: Attachment): AttachmentMetadat
fileSizeLabel: attachment.size ? formatFileSize(Number(attachment.size)) : undefined,
});
// Audio elements render with preload="none" and no src so the feed fetches nothing;
// the source is bound on first play. getAttribute is compared (not .src, which
// reflects an absolute URL) so an already-bound source is not re-assigned.
export const toggleAudioPlayback = async (audio: HTMLAudioElement, sourceUrl: string, onPlayFailure: () => void): Promise<void> => {
if (!audio.paused) {
audio.pause();
return;
}
if (audio.getAttribute("src") !== sourceUrl) {
audio.setAttribute("src", sourceUrl);
}
try {
await audio.play();
} catch {
onPlayFailure();
}
};
export const formatAudioTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) {
return "0:00";
@@ -55,6 +55,7 @@ const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) =>
alt={item.filename}
className="w-10 h-10 rounded border border-border object-cover bg-muted/40"
loading="lazy"
decoding="async"
/>
{item.kind === "motion" && (
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 py-0.5 text-[8px] font-semibold leading-none text-white">
+50 -33
View File
@@ -1,25 +1,29 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ComponentType, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
import { findTagMetadata } from "@/lib/tag";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { lazyWithReload } from "@/utils/lazy";
import { isSuperUser } from "@/utils/user";
import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog";
import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types";
const MemoShareImageDialog = lazyWithReload(() => import("../MemoActionMenu/MemoShareImageDialog"));
const PreviewImageDialog = lazyWithReload(() => import("../PreviewImageDialog"));
const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
const cardRef = useRef<HTMLDivElement>(null);
const [showEditor, setShowEditor] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
const [cardWidth, setCardWidth] = useState(0);
const currentUser = useCurrentUser();
@@ -36,7 +40,14 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const openEditor = useCallback(() => setShowEditor(true), []);
const openEditor = useCallback(() => {
void loadMemoEditor()
.then(({ default: MemoEditor }) => {
setEditorComponent(() => MemoEditor);
setShowEditor(true);
})
.catch(() => undefined);
}, []);
const closeEditor = useCallback(() => setShowEditor(false), []);
const location = useLocation();
@@ -101,20 +112,6 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
],
);
if (showEditor) {
return (
<MemoEditor
autoFocus
className="mb-2"
cacheKey={`inline-memo-editor-${memoData.name}`}
memo={memoData}
parentMemoName={memoData.parent || undefined}
onConfirm={closeEditor}
onCancel={closeEditor}
/>
);
}
const article = (
<article
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
@@ -125,28 +122,48 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
<MemoBody compact={compact} />
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
items={previewState.items}
initialIndex={previewState.index}
/>
{previewState.items.length > 0 && (
<Suspense fallback={null}>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
items={previewState.items}
initialIndex={previewState.index}
/>
</Suspense>
)}
{props.onShareImageDialogOpenChange && (
<MemoShareImageDialog open={Boolean(props.shareImageDialogOpen)} onOpenChange={props.onShareImageDialogOpenChange} />
{props.onShareImageDialogOpenChange && props.shareImageDialogOpen && (
<Suspense fallback={null}>
<MemoShareImageDialog open onOpenChange={props.onShareImageDialogOpenChange} />
</Suspense>
)}
</article>
);
const memoDisplay = showCommentPreview ? (
<div className="w-full mb-2">
{article}
<MemoCommentListView />
</div>
) : (
article
);
return (
<MemoViewContext.Provider value={contextValue}>
{showCommentPreview ? (
<div className="w-full mb-2">
{article}
<MemoCommentListView />
</div>
{showEditor && EditorComponent ? (
<EditorComponent
autoFocus
className="mb-2"
cacheKey={`inline-memo-editor-${memoData.name}`}
memo={memoData}
parentMemoName={memoData.parent || undefined}
onConfirm={closeEditor}
onCancel={closeEditor}
/>
) : (
article
memoDisplay
)}
</MemoViewContext.Provider>
);
+6 -2
View File
@@ -28,6 +28,9 @@ const MotionPhotoPlayer = ({
}: MotionPhotoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
// Latched on first activation: the source stays bound afterwards so repeated
// hovers replay the already-buffered video instead of refetching it.
const [hasActivated, setHasActivated] = useState(false);
const resetPlaybackPosition = useCallback(
(video: HTMLVideoElement) => {
@@ -83,6 +86,7 @@ const MotionPhotoPlayer = ({
return;
}
setHasActivated(true);
void startPlayback(loop);
}, [active, loop, startPlayback, stopPlayback]);
@@ -98,7 +102,7 @@ const MotionPhotoPlayer = ({
/>
<video
ref={videoRef}
src={motionUrl}
src={active || hasActivated ? motionUrl : undefined}
poster={posterUrl}
className={cn(
"pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200",
@@ -108,7 +112,7 @@ const MotionPhotoPlayer = ({
)}
muted
playsInline
preload="metadata"
preload="none"
disableRemotePlayback
onLoadedMetadata={(event) => resetPlaybackPosition(event.currentTarget)}
onEnded={() => stopPlayback()}
@@ -1,8 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
import { type ReactElement, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
@@ -18,7 +17,6 @@ import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters";
import Placeholder from "../Placeholder";
import { estimateMemoCardHeight } from "./memoCardHeight";
@@ -46,8 +44,8 @@ interface Props {
pageSize?: number;
showCreator?: boolean;
enabled?: boolean;
/** When true, render the inline MemoEditor above the list (e.g. on the Home page). */
showMemoEditor?: boolean;
/** Route-owned content rendered before the list and inside column one in grid mode. */
renderLeading?: (options: { useGrid: boolean }) => ReactNode;
}
function useAutoFetchWhenNotScrollable({
@@ -132,9 +130,6 @@ const PagedMemoList = (props: Props) => {
// pages don't each repeat the policy.
const effectiveCompact = compactMode || useGrid;
const showMemoEditor = props.showMemoEditor ?? false;
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
{
state: props.state || State.NORMAL,
@@ -202,16 +197,7 @@ const PagedMemoList = (props: Props) => {
return () => window.removeEventListener("scroll", handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// In the grid the leading stack owns all spacing (GRID_GAP), so the composer carries
// its own bottom margin only in the flow layout.
const memoEditor = showMemoEditor ? (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
) : null;
const leadingContent = props.renderLeading?.({ useGrid });
// A freshly created memo is hoisted to the front; pin it to the top of column one so it
// appears right under the composer instead of dropping into a random (shortest) column.
@@ -232,9 +218,9 @@ const PagedMemoList = (props: Props) => {
// grid's x-spacing exactly.
const hasFilters = filters.length > 0;
const gridLeading =
memoEditor || hasFilters || emptyPlaceholder ? (
leadingContent || hasFilters || emptyPlaceholder ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{memoEditor}
{leadingContent}
<MemoFilters />
{emptyPlaceholder}
</div>
@@ -277,7 +263,7 @@ const PagedMemoList = (props: Props) => {
</>
) : (
<>
{memoEditor}
{leadingContent}
<MemoFilters className="mb-2" />
{sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{emptyPlaceholder}
+43 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface VideoPosterProps {
@@ -9,6 +9,7 @@ interface VideoPosterProps {
}
const MAX_POSTER_DIMENSION = 960;
const POSTER_LOAD_MARGIN = "200px 0px";
const getFrameSourceUrl = (sourceUrl: string): string => {
if (!sourceUrl || sourceUrl.startsWith("blob:") || sourceUrl.startsWith("data:") || sourceUrl.includes("#")) {
@@ -27,14 +28,44 @@ const getCanvasSize = (width: number, height: number) => {
};
const VideoPoster = ({ sourceUrl, alt, className, posterUrl }: VideoPosterProps) => {
const placeholderRef = useRef<HTMLDivElement>(null);
const usablePosterUrl = posterUrl && posterUrl !== sourceUrl ? posterUrl : undefined;
const [capturedPosterUrl, setCapturedPosterUrl] = useState<string>();
const [capturedPoster, setCapturedPoster] = useState<{ sourceUrl: string; url: string }>();
const [nearViewport, setNearViewport] = useState(false);
const frameSourceUrl = useMemo(() => getFrameSourceUrl(sourceUrl), [sourceUrl]);
const capturedPosterUrl = capturedPoster?.sourceUrl === sourceUrl ? capturedPoster.url : undefined;
const posterImageUrl = usablePosterUrl ?? capturedPosterUrl;
useEffect(() => {
setCapturedPosterUrl(undefined);
}, [sourceUrl, usablePosterUrl]);
if (posterImageUrl || nearViewport) {
return;
}
const placeholder = placeholderRef.current;
if (!placeholder) {
return;
}
if (typeof IntersectionObserver === "undefined") {
setNearViewport(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
setNearViewport(true);
observer.disconnect();
},
{ rootMargin: POSTER_LOAD_MARGIN },
);
observer.observe(placeholder);
return () => observer.disconnect();
}, [posterImageUrl, nearViewport]);
const captureFrame = useCallback(
(video: HTMLVideoElement) => {
@@ -54,23 +85,29 @@ const VideoPoster = ({ sourceUrl, alt, className, posterUrl }: VideoPosterProps)
}
context.drawImage(video, 0, 0, width, height);
setCapturedPosterUrl(canvas.toDataURL("image/jpeg", 0.86));
setCapturedPoster({ sourceUrl, url: canvas.toDataURL("image/jpeg", 0.86) });
} catch {
// Cross-origin or codec-specific failures should keep the video fallback visible.
}
},
[capturedPosterUrl, usablePosterUrl],
[capturedPosterUrl, sourceUrl, usablePosterUrl],
);
if (posterImageUrl) {
return <img src={posterImageUrl} alt={alt} className={className} loading="lazy" decoding="async" />;
}
if (!nearViewport) {
return <div ref={placeholderRef} data-testid="video-poster-placeholder" role="img" aria-label={alt} className={className} />;
}
return (
<video
data-testid="video-poster-fallback"
src={frameSourceUrl}
className={cn("pointer-events-none", className)}
role="img"
aria-label={alt}
muted
playsInline
preload="auto"
@@ -1,5 +1,6 @@
import { lazy, Suspense } from "react";
import { Suspense } from "react";
import { cn } from "@/lib/utils";
import { lazyWithReload } from "@/utils/lazy";
import type { MapPoint } from "./types";
interface LazyLocationPickerProps {
@@ -9,7 +10,7 @@ interface LazyLocationPickerProps {
className?: string;
}
const LocationPicker = lazy(() => import("./LocationPicker"));
const LocationPicker = lazyWithReload(() => import("./LocationPicker"));
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
return (
+16 -1
View File
@@ -1,15 +1,23 @@
import { useMemo } from "react";
import MemoEditor from "@/components/MemoEditor";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Home = () => {
const user = useCurrentUser();
const t = useTranslate();
const { isInitialized } = useInstance();
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
const memoFilter = useMemoFilters({
creatorName: user?.name,
@@ -33,7 +41,14 @@ const Home = () => {
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
showMemoEditor
renderLeading={({ useGrid }) => (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
)}
/>
</NewMemoProvider>
</div>
+3 -2
View File
@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, MapIcon } from "lucide-react";
import { lazy, Suspense } from "react";
import { Suspense } from "react";
import { toast } from "react-hot-toast";
import { useParams, useSearchParams } from "react-router-dom";
import MemoView from "@/components/MemoView";
@@ -13,10 +13,11 @@ import { useUser } from "@/hooks/useUserQueries";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { lazyWithReload } from "@/utils/lazy";
type TabView = "memos" | "map";
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
const UserMemoMap = lazyWithReload(() => import("@/components/UserMemoMap"));
interface User {
name: string;
+1 -18
View File
@@ -1,29 +1,12 @@
import { lazy } from "react";
import { createBrowserRouter, Navigate, type RouteObject } from "react-router-dom";
import App from "@/App";
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout";
import { lazyWithReload } from "@/utils/lazy";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
import { ROUTES } from "./routes";
// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
function lazyWithReload<T extends React.ComponentType>(factory: () => Promise<{ default: T }>) {
return lazy(() =>
factory().catch((error) => {
const isChunkError =
error?.message?.includes("Failed to fetch dynamically imported module") ||
error?.message?.includes("Importing a module script failed");
const reloadKey = "chunk-reload";
if (isChunkError && !sessionStorage.getItem(reloadKey)) {
sessionStorage.setItem(reloadKey, "1");
window.location.reload();
}
throw error;
}),
);
}
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
const About = lazyWithReload(() => import("@/pages/About"));
const Archived = lazyWithReload(() => import("@/pages/Archived"));
+28
View File
@@ -0,0 +1,28 @@
import type { ComponentType } from "react";
import { lazy } from "react";
const reloadOnChunkLoadError = (error: unknown) => {
const message = error instanceof Error ? error.message : "";
const isChunkError =
message.includes("Failed to fetch dynamically imported module") || message.includes("Importing a module script failed");
const reloadKey = "chunk-reload";
if (isChunkError && !sessionStorage.getItem(reloadKey)) {
sessionStorage.setItem(reloadKey, "1");
window.location.reload();
}
};
export async function loadWithReload<T>(factory: () => Promise<T>): Promise<T> {
try {
return await factory();
} catch (error) {
reloadOnChunkLoadError(error);
throw error;
}
}
// Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment).
// biome-ignore lint/suspicious/noExplicitAny: mirrors React.lazy's own constraint
export function lazyWithReload<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>) {
return lazy(() => loadWithReload(factory));
}
+52
View File
@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Image } from "@/components/MemoContent/markdown/Image";
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
import MotionPhotoPlayer from "@/components/MotionPhotoPlayer";
describe("media loading", () => {
it("marks markdown images for native lazy loading", () => {
render(<Image src="/image.jpg" alt="memo illustration" />);
const image = screen.getByRole("img", { name: "memo illustration" });
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("decoding", "async");
});
it("does not bind an audio source until playback is requested", async () => {
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
const { container } = render(
<AudioAttachmentItem filename="recording.mp3" sourceUrl="/recording.mp3" mimeType="audio/mpeg" />,
);
const audio = container.querySelector("audio");
expect(audio).not.toHaveAttribute("src");
expect(audio).toHaveAttribute("preload", "none");
fireEvent.click(screen.getByRole("button", { name: "Play recording.mp3" }));
expect(audio).toHaveAttribute("src", "/recording.mp3");
expect(play).toHaveBeenCalledOnce();
});
it("defers binding the motion video until first activation, then keeps it for replays", () => {
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
const pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
const { container, rerender } = render(
<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active={false} />,
);
const video = container.querySelector("video");
expect(video).not.toHaveAttribute("src");
expect(video).toHaveAttribute("preload", "none");
rerender(<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active />);
expect(video).toHaveAttribute("src", "/motion.mp4");
// Deactivating pauses playback but keeps the buffered source bound.
rerender(<MotionPhotoPlayer posterUrl="/poster.jpg" motionUrl="/motion.mp4" alt="Live photo" active={false} />);
expect(pause).toHaveBeenCalled();
expect(video).toHaveAttribute("src", "/motion.mp4");
});
});
@@ -0,0 +1,31 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { highlightCode } from "@/components/MemoContent/highlight";
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
import { hasMathSyntax } from "@/components/MemoContent/math";
describe("memo content lazy renderers", () => {
it("detects math in prose but ignores escaped dollars and code", () => {
expect(hasMathSyntax("Inline $L$ formula")).toBe(true);
expect(hasMathSyntax("Display $$x$$ formula")).toBe(true);
expect(hasMathSyntax("Display\n$$\nx + y\n$$")).toBe(true);
expect(hasMathSyntax(String.raw`Price is \$5`)).toBe(false);
expect(hasMathSyntax("Inline code: `$L$`")).toBe(false);
expect(hasMathSyntax("```text\n$L$\n```")).toBe(false);
});
it("loads the math renderer when math syntax is present", async () => {
const { container } = render(<MemoMarkdownRenderer content="$L$" resolvedMentionUsernames={new Set()} />);
await waitFor(() => expect(container.querySelector(".katex")).not.toBeNull());
});
it("escapes plain code and highlights common languages", async () => {
expect(await highlightCode('<script data-test="x">&</script>', "")).toBe(
"&lt;script data-test=&quot;x&quot;&gt;&amp;&lt;/script&gt;",
);
expect(await highlightCode("echo hello", "bash")).toContain("hljs-built_in");
expect(await highlightCode("const value = 1;", "js")).toContain("hljs-keyword");
});
});
+34 -1
View File
@@ -1,7 +1,9 @@
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { toBlob } from "html-to-image";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMemoShareImageFileName,
createMemoShareImageBlob,
getMemoShareDialogWidth,
getMemoSharePreviewAvatarUrl,
getMemoSharePreviewWidth,
@@ -11,6 +13,10 @@ import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/mem
import { AttachmentSchema, type Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoSchema, type Memo } from "@/types/proto/api/v1/memo_service_pb";
vi.mock("html-to-image", () => ({
toBlob: vi.fn(),
}));
const buildMemo = (overrides: Partial<Memo> = {}) =>
create(MemoSchema, {
name: "memos/test",
@@ -106,6 +112,10 @@ describe("memo share image preview model", () => {
});
describe("memo share image utilities", () => {
beforeEach(() => {
vi.mocked(toBlob).mockResolvedValue(new Blob(["preview"], { type: "image/png" }));
});
it("builds filenames from memo resource names", () => {
expect(buildMemoShareImageFileName("memos/abc123")).toBe("memo-abc123.png");
expect(buildMemoShareImageFileName("")).toBe("memo-memo.png");
@@ -131,4 +141,27 @@ describe("memo share image utilities", () => {
expect(getMemoSharePreviewAvatarUrl("data:image/png;base64,abc")).toBe("data:image/png;base64,abc");
expect(getMemoSharePreviewAvatarUrl("https://example.com/avatar.png")).toBeUndefined();
});
it("promotes lazy images and stops waiting after the asset timeout", async () => {
vi.useFakeTimers();
try {
const preview = document.createElement("div");
const image = document.createElement("img");
image.loading = "lazy";
Object.defineProperty(image, "complete", { configurable: true, value: false });
preview.appendChild(image);
const renderPromise = createMemoShareImageBlob(preview);
await vi.advanceTimersByTimeAsync(0);
expect(image.loading).toBe("eager");
expect(toBlob).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5000);
await expect(renderPromise).resolves.toBeInstanceOf(Blob);
expect(toBlob).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});
+8 -12
View File
@@ -37,19 +37,15 @@ vi.mock("@/components/MemoFilters", () => ({
default: () => <div data-testid="memo-filters" />,
}));
vi.mock("@/components/MemoEditor", () => ({
default: () => <div data-testid="memo-editor" />,
}));
const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo;
const renderList = (
renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () => <div />,
options: { showMemoEditor?: boolean } = {},
options: { leading?: React.ReactNode } = {},
) =>
render(
<QueryClientProvider client={new QueryClient()}>
<PagedMemoList renderer={renderer} showMemoEditor={options.showMemoEditor} />
<PagedMemoList renderer={renderer} renderLeading={options.leading ? () => options.leading : undefined} />
</QueryClientProvider>,
);
@@ -67,23 +63,23 @@ describe("<PagedMemoList>", () => {
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("shows the empty state below the memo editor", () => {
renderList(undefined, { showMemoEditor: true });
it("shows the empty state below route-owned leading content", () => {
renderList(undefined, { leading: <div data-testid="leading-content" /> });
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
expect(screen.getByTestId("leading-content")).toBeInTheDocument();
expect(screen.getByText("No data found.")).toBeInTheDocument();
expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument();
});
it("places the empty state in the first grid column", () => {
it("places leading content and the empty state in the first grid column", () => {
view.maxColumns = 0;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
renderList(undefined, { showMemoEditor: true });
renderList(undefined, { leading: <div data-testid="leading-content" /> });
const leadingTile = screen.getByText("No data found.").closest(".absolute");
expect(leadingTile).not.toBeNull();
expect(leadingTile).toContainElement(screen.getByTestId("memo-editor"));
expect(leadingTile).toContainElement(screen.getByTestId("leading-content"));
} finally {
widthSpy.mockRestore();
}
+54 -3
View File
@@ -1,12 +1,49 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import VideoPoster from "@/components/VideoPoster";
describe("<VideoPoster>", () => {
it("renders a mobile-friendly video fallback before a frame is captured", () => {
let intersectionCallback: IntersectionObserverCallback;
beforeEach(() => {
class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "200px 0px";
readonly thresholds = [0];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = vi.fn();
observe = vi.fn();
takeRecords = vi.fn(() => []);
unobserve = vi.fn();
}
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const enterViewport = () => {
act(() => {
intersectionCallback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
};
it("waits until the poster is near the viewport before loading video data", () => {
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
expect(screen.getByTestId("video-poster-placeholder")).toBeInTheDocument();
expect(screen.queryByTestId("video-poster-fallback")).not.toBeInTheDocument();
enterViewport();
const video = screen.getByTestId("video-poster-fallback");
expect(video).toHaveAttribute("src", "/file/attachments/video/video.mp4#t=0.001");
expect(video).toHaveAttribute("preload", "auto");
expect(video).toHaveAttribute("playsinline");
expect((video as HTMLVideoElement).muted).toBe(true);
@@ -32,6 +69,7 @@ describe("<VideoPoster>", () => {
});
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" alt="clip.mp4" className="object-cover" />);
enterViewport();
const video = screen.getByTestId("video-poster-fallback") as HTMLVideoElement;
Object.defineProperty(video, "videoWidth", { configurable: true, value: 640 });
@@ -44,4 +82,17 @@ describe("<VideoPoster>", () => {
});
expect(drawImage).toHaveBeenCalledWith(video, 0, 0, 640, 360);
});
it("uses an available poster without loading the video fallback", () => {
render(
<VideoPoster
sourceUrl="/file/attachments/video/video.mp4"
posterUrl="/file/attachments/video/poster.webp"
alt="clip.mp4"
/>,
);
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "/file/attachments/video/poster.webp");
expect(screen.queryByTestId("video-poster-fallback")).not.toBeInTheDocument();
});
});