feat(web): add a global memo composer to the sidebar (#6219)

This commit is contained in:
amblued 2026-08-21 21:14:28 +08:00 committed by GitHub
parent 57a4b7aee6
commit f36ca9317d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 720 additions and 173 deletions

View file

@ -9,9 +9,9 @@
"dev": "vite",
"build": "vite build",
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
"lint": "tsc --noEmit --skipLibCheck && biome check src",
"lint:fix": "biome check --write src",
"format": "biome format --write src",
"lint": "tsc --noEmit --skipLibCheck && biome check src tests",
"lint:fix": "biome check --write src tests",
"format": "biome format --write src tests",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"

View file

@ -20,6 +20,7 @@ import {
PaperclipIcon,
PlusIcon,
SearchIcon,
SquarePenIcon,
Trash2Icon,
UserRoundIcon,
} from "lucide-react";
@ -39,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { memoViewServiceClient } from "@/connect";
import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
import { useInstance } from "@/contexts/InstanceContext";
import { stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
@ -72,6 +74,32 @@ import SidebarSection, {
import TagsSection from "./TagsSection";
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground";
const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
const t = useTranslate();
const label = t("editor.new-memo");
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={SIDEBAR_HEADER_ACTION_CLASSES}
onClick={onClick}
aria-label={label}
data-new-memo-trigger
/>
}
>
<SquarePenIcon className="size-4" strokeWidth={1.8} />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
};
const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
const t = useTranslate();
@ -604,6 +632,7 @@ const AppSidebar = ({ className }: { className?: string }) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
const { canOpen: canCompose, openEditor } = useGlobalMemoEditor();
return (
<aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}>
<div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}>
@ -613,18 +642,21 @@ const AppSidebar = ({ className }: { className?: string }) => {
>
<MemosLogo compact />
</Link>
<Button
variant="ghost"
size="icon-sm"
className="size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground"
onClick={() => {
setMobileOpen(false);
setQuickFindOpen(true);
}}
aria-label={t("common.search")}
>
<SearchIcon className="size-4" strokeWidth={1.8} />
</Button>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
className={SIDEBAR_HEADER_ACTION_CLASSES}
onClick={() => {
setMobileOpen(false);
setQuickFindOpen(true);
}}
aria-label={t("common.search")}
>
<SearchIcon className="size-4" strokeWidth={1.8} />
</Button>
{canCompose && <NewMemoAction onClick={openEditor} />}
</div>
</div>
<GlobalNavigation />
<div className="mx-3 mt-2 border-t border-border/70" />
@ -660,7 +692,14 @@ export const MobileAppHeader = () => {
const { setMobileOpen } = useAppSidebar();
return (
<header className="sticky top-0 z-20 flex h-12 w-full items-center justify-start gap-1 border-b border-border/70 bg-background/90 px-2 backdrop-blur-md md:hidden">
<Button variant="ghost" size="icon-sm" className="size-8" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Button
variant="ghost"
size="icon-sm"
className="size-8"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
data-mobile-navigation-trigger
>
<MenuIcon className="size-[18px]" />
</Button>
<Link

View file

@ -42,18 +42,6 @@ const QuickFindDialog = () => {
const scopeLabel =
viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setQuickFindOpen(true);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setQuickFindOpen]);
useEffect(() => {
if (!quickFindOpen) return;
setQuery(

View file

@ -13,8 +13,10 @@ MemoEditor is a three-layer component. At its core is a single editor — `Edito
└─────────────────┬───────────────────────┘
│ EditorController
┌─────────────────▼───────────────────────┐
│ State Layer (Reducer + Context) │
│ - state/, useEditorContext() │
│ State Layer (reducer over an external │
│ store; per-slice subscriptions) │
│ - state/, useEditorContext(), │
│ useEditorSelector() │
│ - state.content ← markdown (the │
│ single source of truth) │
└─────────────────┬───────────────────────┘
@ -29,16 +31,18 @@ MemoEditor is a three-layer component. At its core is a single editor — `Edito
```
MemoEditor/
├── index.tsx # The shell: EditorProvider + MemoEditorImpl
├── loader.ts # loadMemoEditor(): the shared lazy-load entry point
├── state/ # State management (reducer, actions, context)
├── services/ # Business logic (pure functions)
├── components/ # UI components
│ ├── EditorContent.tsx # Hosts Editor; forwards its EditorController ref
│ ├── EditorToolbar.tsx # Toolbar
│ ├── EditorMetadata.tsx # Attachment strip below the document
│ └── ...
├── hooks/ # React hooks (utilities)
│ ├── useMemoSave.ts # Save transaction, cache invalidation, and reset
│ └── useFocusMode.ts # Scroll lock and layout-stable focus presentation
├── Editor/ # The CodeMirror 6 decorated-source editor
├── Editor/ # The CodeMirror 6 decorated-source editor
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the
│ │ # controller refs, syncs initialContent in/out
│ ├── extensions.ts # buildEditorExtensions(): assembles the CM extension set
@ -46,31 +50,35 @@ MemoEditor/
│ ├── tagMentionDecorations.ts# ViewPlugin that decorates #tag / @mention spans
│ ├── markdownTagRanges.ts # Markdown syntax-tree adapter for the shared tag scanner
│ ├── tagAutocomplete.ts # CM autocompletion source for #tag
│ ├── uploadAnchors.ts # Widget decorations holding a slot per in-flight upload
│ ├── formatting.ts # FormattingController impl (toggle marks, headings, lists)
│ └── controller.ts # EditorController impl over an EditorView
│ ├── controller.ts # EditorController impl over an EditorView
│ └── ... # Heading/list/viewport decorations, editor.css
├── formatting/
│ └── commands.ts # Backend-agnostic catalog of formatting verbs
├── Toolbar/ # Toolbar sub-components (InsertMenu, VisibilitySelector)
├── Toolbar/ # EditorToolbar, FormattingToolbar, InsertMenu, VisibilitySelector
├── constants.ts
└── types/
└── editorController.ts # EditorController / FormattingController interfaces
└── types/ # EditorController / FormattingController, component props,
# attachment and insert-menu types
```
## Key Concepts
### State Management
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
A reducer (`state/reducer.ts`) drives an **external store**, not a `useReducer` in the provider, and consumers subscribe to just the slice they read via `useEditorSelector` (`useSyncExternalStore` under the hood). Content changes on every keystroke, so routing it through a single context value would re-render every consumer — toolbar, insert menu, metadata — per keystroke; with per-slice subscriptions only the components whose slice actually changed re-render. All state changes still go through action creators.
`state.content` holds the document as a **markdown string** and is the single source of truth. Because the editor stores markdown verbatim, `state.content` is exactly the editor's document — there is no encoding or normalization step.
### The editor contract
`types/editorController.ts` defines `EditorController` — `focus`, `getMarkdown`, `setMarkdown`, `insertMarkdown`, `selectAll`, `scrollToCursor`, plus an optional `formatting` capability. Callers outside the editor implementation use this interface exclusively and never reach into CodeMirror internals.
`types/editorController.ts` defines `EditorController` — document access (`getMarkdown`, `setMarkdown`, `insertMarkdown`), cursor and focus (`focus`, `hasFocus`, `isEmpty`, `getCursor`, `setCursor`, `scrollToCursor`, `selectAll`), the upload-anchor group below, plus an optional `formatting` capability. Callers outside the editor implementation use this interface exclusively and never reach into CodeMirror internals.
`Editor/controller.ts` implements `EditorController` over a CodeMirror `EditorView`: `getMarkdown` is just `view.state.doc.toString()`, `setMarkdown` replaces the whole document, and `insertMarkdown` block-pads the insertion so it lands as its own block.
`FormattingController` (same file in `types/`) is the rich-formatting surface the focus-mode `FormattingToolbar` drives: `run(commandId, ctx?)`, `getActiveFormats()`, `getSelectedText()`, and `subscribe(listener)`. `Editor/formatting.ts` implements it by editing the markdown source directly — toggling inline marks (`**`/`*`/`` ` ``), line prefixes (`- `, `1. `, `- [ ] `), and ATX heading prefixes (`#`…) — and by reading active state from the Lezer syntax tree at the caret.
`createUploadAnchor`/`updateUploadAnchor`/`resolveUploadAnchor`/`cancelUploadAnchor` drive `Editor/uploadAnchors.ts`, a `StateField` of widget decorations that hold a place in the document while an attachment uploads and carry its progress, failure message, and retry/keep affordances. `resolveUploadAnchor` replaces the anchor with the finished markdown (block-padded the same way `insertMarkdown` is); resolving with empty markdown cancels instead, as does `cancelUploadAnchor`.
`FormattingController` (same file in `types/`) is the rich-formatting surface the focus-mode `FormattingToolbar` drives: `run(commandId, ctx?)`, `getActiveFormats()`, and `subscribe(listener)`. `Editor/formatting.ts` implements it by editing the markdown source directly — toggling inline marks (`**` and `*`) and single-backtick code delimiters, line prefixes (`-`, `1.`, and `- [ ]`, each followed by a space), and ATX heading prefixes (`#`…) — and by reading active state from the Lezer syntax tree at the caret.
### Formatting command catalog
@ -78,7 +86,7 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
### Editor extensions
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned.
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. It also binds the save shortcut: `Meta-Enter` and `Ctrl-Enter` both call `onSubmit`, bound explicitly rather than through the platform-dependent `Mod-` so either works everywhere, and ordered ahead of `defaultKeymap`'s own `Mod-Enter` (`insertBlankLine`) so saving never also edits the document. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned.
`Editor/theme.ts` defines the decorated-source look: a `HighlightStyle` over the Lezer markdown highlight tags (headings, strong, emphasis, code, links, quotes, markers) and an `EditorView.theme`. Colors come from CSS custom properties so light/dark themes just work. This is the editor's own styling — the read-only memo view styles itself separately via `@/lib/markdownStyles`.
@ -111,8 +119,9 @@ Thin presentation components that dispatch actions and render UI.
```typescript
import MemoEditor from "@/components/MemoEditor";
// Create mode: omit `memo`. Pass an existing Memo to edit it instead.
<MemoEditor
memoName="memos/123"
cacheKey="home-composer"
onConfirm={(name) => console.log('Saved:', name)}
onCancel={() => console.log('Cancelled')}
/>
@ -123,6 +132,6 @@ import MemoEditor from "@/components/MemoEditor";
Services are pure functions — easy to unit test without React.
```typescript
const state = mockEditorState();
const state = createInitialState(); // from state/types.ts
const result = await memoService.save(state, { memoName: 'memos/123' });
```

View file

@ -45,6 +45,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
defaultCreateTime,
onConfirm,
onCancel,
onSavingChange,
}) => {
const t = useTranslate();
const currentUser = useCurrentUser();
@ -54,6 +55,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// typing (which changes content) does not re-render the editor shell and its
// toolbar/metadata children.
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
const isSaving = useEditorSelector((s) => s.ui.isLoading.saving);
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
const { userGeneralSetting } = useAuth();
const { aiSetting, fetchSetting } = useInstance();
@ -89,6 +91,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
});
const isDraftCacheEnabled = !memo;
useEffect(() => {
onSavingChange?.(isSaving);
}, [isSaving, onSavingChange]);
// Auto-save content to localStorage (subscribes to the store internally).
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);

View file

@ -25,6 +25,8 @@ export interface MemoEditorProps {
defaultCreateTime?: Date;
onConfirm?: (memoName: string) => void;
onCancel?: () => void;
/** Reports save activity so external presentations can prevent dismissal mid-transaction. */
onSavingChange?: (isSaving: boolean) => void;
}
export interface EditorContentProps {

View file

@ -0,0 +1,164 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { VisuallyHidden } from "@/components/ui/visually-hidden";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useTranslate } from "@/utils/i18n";
interface GlobalMemoEditorContextValue {
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
canOpen: boolean;
openEditor: () => void;
}
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
const isVisibleFocusTarget = (element: HTMLElement | null): element is HTMLElement => {
if (!element?.isConnected || element.tabIndex < 0 || element.matches(":disabled, [aria-disabled='true']")) return false;
// Walk ancestors: the mobile trigger is hidden by a `md:hidden` parent, which
// never shows up in the element's own computed style.
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
const style = window.getComputedStyle(current);
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
}
return true;
};
const findVisibleFocusTarget = (selector: string): HTMLElement | null =>
Array.from(document.querySelectorAll<HTMLElement>(selector)).find(isVisibleFocusTarget) ?? null;
export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) {
const t = useTranslate();
const currentUserName = useCurrentUser()?.name;
const { isUserSettingsInitialized } = useAuth();
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
// Keyed by the user who opened it, so signing out closes the composer in the
// same render and a different user signing in cannot resurrect it.
const [openedFor, setOpenedFor] = useState<string>();
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
// Only ever read from event handlers, so a ref keeps each save from
// re-rendering the provider and the whole editor tree it hosts.
const isSavingRef = useRef(false);
const returnFocusRef = useRef<HTMLElement | null>(null);
const openRequestVersionRef = useRef(0);
const closeEditor = useCallback(() => {
openRequestVersionRef.current += 1;
isSavingRef.current = false;
setOpenedFor(undefined);
}, []);
const requestCloseEditor = useCallback(() => {
if (!isSavingRef.current) closeEditor();
}, [closeEditor]);
const reportSaving = useCallback((saving: boolean) => {
isSavingRef.current = saving;
}, []);
// The editor reads defaults out of user settings, so composing has to wait for
// them. Keep the rule here so every entry point uses the same gate.
const canOpen = Boolean(currentUserName) && isUserSettingsInitialized;
const openEditor = useCallback(() => {
if (!canOpen || !currentUserName) return;
// Owned here so no caller can leave a sidebar surface open underneath.
setMobileOpen(false);
setQuickFindOpen(false);
const requestVersion = ++openRequestVersionRef.current;
isSavingRef.current = false;
const activeElement = document.activeElement;
returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null;
void loadMemoEditor()
.then(({ default: MemoEditor }) => {
if (openRequestVersionRef.current !== requestVersion) return;
setEditorComponent(() => MemoEditor);
setOpenedFor(currentUserName);
})
.catch(() => undefined);
}, [canOpen, currentUserName, setMobileOpen, setQuickFindOpen]);
useEffect(() => {
// RootLayout remains mounted when a public instance moves from Home to
// Explore on sign-out. Treat every auth/readiness transition as a session
// boundary so an open composer—or a pending lazy import—cannot survive it.
closeEditor();
returnFocusRef.current = null;
}, [closeEditor, currentUserName, isUserSettingsInitialized]);
const resolveFinalFocus = useCallback(() => {
// Release the remembered node here rather than in closeEditor: base-ui asks
// for it after close, and a trigger that unmounted with the drawer would
// otherwise keep its whole detached subtree alive until the next open.
const remembered = isVisibleFocusTarget(returnFocusRef.current) ? returnFocusRef.current : null;
returnFocusRef.current = null;
return remembered ?? findVisibleFocusTarget("[data-new-memo-trigger]") ?? findVisibleFocusTarget("[data-mobile-navigation-trigger]");
}, []);
const editorIsOpen = openedFor !== undefined && openedFor === currentUserName;
const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]);
return (
<GlobalMemoEditorContext.Provider value={value}>
{children}
<DialogPrimitive.Root
open={editorIsOpen}
onOpenChange={(open, eventDetails) => {
if (!open && isSavingRef.current) {
eventDetails.cancel();
return;
}
if (open) {
setOpenedFor(currentUserName);
} else {
closeEditor();
}
}}
>
{EditorComponent && (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="fixed inset-0 z-overlay bg-transparent" />
<DialogPrimitive.Popup
className="fixed inset-0 z-overlay outline-none"
initialFocus
finalFocus={resolveFinalFocus}
aria-modal="true"
>
<VisuallyHidden>
<DialogPrimitive.Title>{t("editor.new-memo")}</DialogPrimitive.Title>
</VisuallyHidden>
<EditorComponent
autoFocus
initialFocusMode
cacheKey="global-memo-editor"
placeholder={t("editor.any-thoughts")}
onConfirm={closeEditor}
onCancel={closeEditor}
onFocusModeExit={requestCloseEditor}
onSavingChange={reportSaving}
/>
<DialogPrimitive.Close className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:end-3 focus-visible:top-3 focus-visible:z-dropdown focus-visible:h-9 focus-visible:rounded-md focus-visible:bg-background focus-visible:px-3 focus-visible:text-sm focus-visible:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
{t("common.close")}
</DialogPrimitive.Close>
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
)}
</DialogPrimitive.Root>
</GlobalMemoEditorContext.Provider>
);
}
export function useGlobalMemoEditor() {
const context = useContext(GlobalMemoEditorContext);
if (!context) {
throw new Error("useGlobalMemoEditor must be used within GlobalMemoEditorProvider");
}
return context;
}

View file

@ -10,6 +10,7 @@ import AppSidebar, {
useSidebarWidth,
} from "@/components/AppSidebar";
import { AppSidebarProvider } from "@/contexts/AppSidebarContext";
import { GlobalMemoEditorProvider } from "@/contexts/GlobalMemoEditorContext";
import { useInstance } from "@/contexts/InstanceContext";
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -67,35 +68,37 @@ const RootLayoutContent = () => {
}
return (
<AppSidebarProvider>
<div ref={shellRef} className="min-h-full w-full bg-background" style={{ [SIDEBAR_WIDTH_VAR]: `${sidebarWidth}px` } as CSSProperties}>
{md && (
<div className="fixed inset-y-0 start-0 z-30 w-(--app-sidebar-width) border-e border-border/70">
<AppSidebar />
<SidebarResizeHandle
width={sidebarWidth}
minWidth={minWidth}
maxWidth={maxWidth}
onWidthChange={setSidebarWidth}
targetRef={shellRef}
/>
</div>
)}
<MobileAppSidebar />
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:ps-(--app-sidebar-width)">
<MobileAppHeader />
{profile.demo && <DemoBanner />}
<Outlet />
</main>
<QuickFindDialog />
</div>
</AppSidebarProvider>
<div ref={shellRef} className="min-h-full w-full bg-background" style={{ [SIDEBAR_WIDTH_VAR]: `${sidebarWidth}px` } as CSSProperties}>
{md && (
<div className="fixed inset-y-0 start-0 z-30 w-(--app-sidebar-width) border-e border-border/70">
<AppSidebar />
<SidebarResizeHandle
width={sidebarWidth}
minWidth={minWidth}
maxWidth={maxWidth}
onWidthChange={setSidebarWidth}
targetRef={shellRef}
/>
</div>
)}
<MobileAppSidebar />
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:ps-(--app-sidebar-width)">
<MobileAppHeader />
{profile.demo && <DemoBanner />}
<Outlet />
</main>
<QuickFindDialog />
</div>
);
};
const RootLayout = () => (
<MemoFilterProvider>
<RootLayoutContent />
<AppSidebarProvider>
<GlobalMemoEditorProvider>
<RootLayoutContent />
</GlobalMemoEditorProvider>
</AppSidebarProvider>
</MemoFilterProvider>
);

View file

@ -269,6 +269,7 @@
"uploading-images": "Uploading images {{completed}}/{{total}}",
"upload-file": "Upload file"
},
"new-memo": "New memo",
"no-changes-detected": "No changes detected",
"save": "Save",
"saving": "Saving...",

View file

@ -187,6 +187,7 @@
"link-prompt": "输入链接地址",
"more": "更多格式"
},
"new-memo": "新建备忘录",
"no-changes-detected": "未检测到更改",
"save": "保存",
"saving": "保存中...",

View file

@ -271,6 +271,7 @@
"uploading-images": "正在上傳圖片 {{completed}}/{{total}}",
"upload-file": "上傳文件"
},
"new-memo": "新增備忘錄",
"no-changes-detected": "未發現變更",
"save": "儲存",
"saving": "儲存中...",

View file

@ -20,10 +20,10 @@ vi.mock("@/contexts/InstanceContext", () => ({
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) =>
(
{
({
"common.version": "Version",
"about.powered-by": "Powered by Memos",
} as Record<string, string>
}) as Record<string, string>
)[key] ?? key,
}));

View file

@ -1,8 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render as testingLibraryRender, screen } from "@testing-library/react";
import { fireEvent, screen, render as testingLibraryRender } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader } from "@/components/AppSidebar";
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
const authState = vi.hoisted(() => ({
@ -11,6 +11,11 @@ const authState = vi.hoisted(() => ({
}));
const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived",
mobileOpen: false,
}));
const globalEditorState = vi.hoisted(() => ({
canOpen: true,
openEditor: vi.fn(),
}));
vi.mock("@/components/MemosLogo", () => ({
@ -41,7 +46,7 @@ vi.mock("@/contexts/AppSidebarContext", () => ({
setInboxFilter: vi.fn(),
memoDetail: undefined,
setMemoDetail: vi.fn(),
mobileOpen: false,
mobileOpen: sidebarState.mobileOpen,
setMobileOpen: vi.fn(),
quickFindOpen: false,
setQuickFindOpen: vi.fn(),
@ -54,6 +59,10 @@ vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
useGlobalMemoEditor: () => globalEditorState,
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: true }),
}));
@ -123,23 +132,40 @@ describe("App sidebar logo", () => {
authState.currentUser = { name: "users/test" };
authState.memoViews = [];
sidebarState.memoScope = "home";
sidebarState.mobileOpen = false;
globalEditorState.canOpen = true;
globalEditorState.openEditor.mockClear();
});
it("navigates home instead of opening a global editor", () => {
it("keeps logo navigation unchanged and opens the global memo editor", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<MemoryRouter initialEntries={["/attachments"]}>
<AppSidebar />
</MemoryRouter>,
);
const logo = screen.getByRole("link", { name: "Memos logo" });
expect(logo).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: /create.*memos/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
});
it("hides Compose when the composer reports it is not available", () => {
globalEditorState.canOpen = false;
render(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
});
it("shows the compact public navigation for a guest", () => {
authState.currentUser = undefined;
globalEditorState.canOpen = false;
render(
<MemoryRouter initialEntries={["/explore"]}>
<AppSidebar />
@ -149,6 +175,7 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
});
@ -303,15 +330,29 @@ describe("App sidebar logo", () => {
expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
});
it("keeps the mobile brand beside navigation without a duplicate search action", () => {
it("keeps the mobile header limited to navigation and brand", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<MemoryRouter initialEntries={["/about"]}>
<MobileAppHeader />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Open navigation" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
});
it("exposes Compose in the mobile navigation drawer", () => {
sidebarState.mobileOpen = true;
render(
<MemoryRouter initialEntries={["/about"]}>
<MobileAppSidebar />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
});
});

View file

@ -5,11 +5,11 @@ import { createElement, type PropsWithChildren } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildAttachmentLibraryStats, useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
import {
type Attachment,
AttachmentSchema,
MotionMediaFamily,
MotionMediaRole,
MotionMediaSchema,
type Attachment,
} from "@/types/proto/api/v1/attachment_service_pb";
const clients = vi.hoisted(() => ({ listAttachments: vi.fn() }));

View file

@ -57,9 +57,7 @@ describe("AuthProvider initialization", () => {
it("resets full readiness while post-sign-in settings are pending", async () => {
let resolveSettings!: (value: { settings: [] }) => void;
clients.getCurrentUser.mockResolvedValue({ user: { name: "users/alice", username: "alice" } });
clients.listUserSettings.mockImplementation(
() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)),
);
clients.listUserSettings.mockImplementation(() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)));
render(<Probe />, { wrapper });

View file

@ -5,7 +5,7 @@ import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
date: "2025-05-01",
label: "1",
label: 1,
count: 0,
isCurrentMonth: true,
isToday: false,

View file

@ -14,16 +14,7 @@ vi.mock("@/hooks/useUserQueries", () => ({
describe("Editor", () => {
it("scopes tag autocomplete stats to the current user", () => {
render(
<Editor
className="x"
initialContent=""
placeholder="memo"
onContentChange={vi.fn()}
onFiles={vi.fn()}
onSubmit={vi.fn()}
/>,
);
render(<Editor className="x" initialContent="" placeholder="memo" onContentChange={vi.fn()} onFiles={vi.fn()} onSubmit={vi.fn()} />);
expect(queries.useTagCounts).toHaveBeenCalledWith(true);
});

View file

@ -38,7 +38,7 @@ describe("useFilteredMemoStats", () => {
vi.mocked(useAllUserStats).mockReturnValue({
data: [],
isLoading: false,
} as ReturnType<typeof useAllUserStats>);
} as unknown as ReturnType<typeof useAllUserStats>);
vi.mocked(useUserStats).mockReturnValue({
data: {
memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)],
@ -46,7 +46,7 @@ describe("useFilteredMemoStats", () => {
tagCount: {},
},
isLoading: false,
} as ReturnType<typeof useUserStats>);
} as unknown as ReturnType<typeof useUserStats>);
});
afterEach(() => {
@ -91,7 +91,7 @@ describe("useFilteredMemoStats", () => {
tagCount: {},
},
isLoading: false,
} as ReturnType<typeof useUserStats>);
} as unknown as ReturnType<typeof useUserStats>);
mockUseView.mockReturnValue({
timeBasis: "update_time",
orderByTimeAsc: false,

View file

@ -17,7 +17,7 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn();
});
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
function makeController(opts: { active?: Partial<ActiveFormatState> } = {}) {
const run = vi.fn();
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
const controller: EditorController = {
@ -31,10 +31,13 @@ function makeController(opts: { active?: Partial<ActiveFormatState>; getSelected
setCursor: vi.fn(),
scrollToCursor: () => {},
selectAll: () => {},
createUploadAnchor: vi.fn(),
updateUploadAnchor: vi.fn(),
resolveUploadAnchor: vi.fn(),
cancelUploadAnchor: vi.fn(),
formatting: {
run,
getActiveFormats: () => activeFormats,
getSelectedText: opts.getSelectedText ?? (() => ""),
subscribe: () => () => {},
},
};

View file

@ -0,0 +1,255 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import { GlobalMemoEditorProvider, useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
const DIALOG = { name: "editor.new-memo" };
const mocks = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
isUserSettingsInitialized: true,
editorProps: undefined as MemoEditorProps | undefined,
loadMemoEditor: vi.fn(),
setMobileOpen: vi.fn(),
setQuickFindOpen: vi.fn(),
}));
vi.mock("@/components/MemoEditor/loader", () => ({
loadMemoEditor: mocks.loadMemoEditor,
}));
vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({ setMobileOpen: mocks.setMobileOpen, setQuickFindOpen: mocks.setQuickFindOpen }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isUserSettingsInitialized: mocks.isUserSettingsInitialized }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => mocks.currentUser,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const MockMemoEditor = (props: MemoEditorProps) => {
mocks.editorProps = props;
return (
<div data-testid="global-editor">
<button type="button" onClick={() => props.onConfirm?.("memos/created")}>
Save memo
</button>
<button type="button" onClick={props.onCancel}>
Cancel editor
</button>
<button type="button" onClick={props.onFocusModeExit}>
Exit focus mode
</button>
<button type="button" onClick={() => props.onSavingChange?.(true)}>
Start saving
</button>
</div>
);
};
const Trigger = () => {
const { openEditor } = useGlobalMemoEditor();
return (
<button type="button" onClick={openEditor}>
Open editor
</button>
);
};
/** Stands in for the mobile drawer: its Compose button unmounts as the drawer closes. */
const EphemeralTrigger = () => {
const { openEditor } = useGlobalMemoEditor();
const [dismissed, setDismissed] = useState(false);
return (
<>
{!dismissed && (
<button
type="button"
data-new-memo-trigger
onClick={() => {
setDismissed(true);
openEditor();
}}
>
Open drawer editor
</button>
)}
<button type="button" data-mobile-navigation-trigger>
Open navigation
</button>
</>
);
};
const renderProvider = (children = <Trigger />) => render(<GlobalMemoEditorProvider>{children}</GlobalMemoEditorProvider>);
/** Renders, focuses the trigger so focus-return has something to restore, and opens. */
const openViaTrigger = async () => {
renderProvider();
const trigger = screen.getByRole("button", { name: "Open editor" });
trigger.focus();
fireEvent.click(trigger);
await screen.findByRole("dialog", DIALOG);
return trigger;
};
const expectClosed = () => waitFor(() => expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument());
describe("GlobalMemoEditorProvider", () => {
beforeEach(() => {
mocks.currentUser = { name: "users/test" };
mocks.isUserSettingsInitialized = true;
mocks.editorProps = undefined;
mocks.loadMemoEditor.mockResolvedValue({ default: MockMemoEditor });
mocks.setMobileOpen.mockClear();
mocks.setQuickFindOpen.mockClear();
});
it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => {
const trigger = await openViaTrigger();
const dialog = screen.getByRole("dialog", DIALOG);
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
expect(mocks.setQuickFindOpen).toHaveBeenCalledWith(false);
expect(mocks.editorProps).toMatchObject({
autoFocus: true,
initialFocusMode: true,
cacheKey: "global-memo-editor",
});
await waitFor(() => expect(dialog).toContainElement(document.activeElement as HTMLElement | null));
fireEvent.keyDown(dialog, { key: "Escape" });
await expectClosed();
expect(trigger).toHaveFocus();
});
it.each(["Save memo", "Cancel editor", "Exit focus mode", "common.close"])("closes after %s", async (action) => {
const trigger = await openViaTrigger();
fireEvent.click(screen.getByRole("button", { name: action }));
await expectClosed();
expect(trigger).toHaveFocus();
});
it("prevents dialog and focus-mode dismissal while saving but lets save completion force close", async () => {
await openViaTrigger();
const dialog = screen.getByRole("dialog", DIALOG);
fireEvent.click(screen.getByRole("button", { name: "Start saving" }));
fireEvent.keyDown(dialog, { key: "Escape" });
expect(dialog).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Exit focus mode" }));
expect(dialog).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
expect(dialog).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save memo" }));
await expectClosed();
});
it("makes the screen-reader close control visible when keyboard-focused", async () => {
await openViaTrigger();
const close = screen.getByRole("button", { name: "common.close" });
expect(close).toHaveClass("sr-only", "focus-visible:not-sr-only");
close.focus();
expect(close).toHaveFocus();
});
it("returns focus to mobile navigation when the drawer's Compose trigger unmounts", async () => {
renderProvider(<EphemeralTrigger />);
fireEvent.click(screen.getByRole("button", { name: "Open drawer editor" }));
await screen.findByRole("dialog", DIALOG);
fireEvent.click(screen.getByRole("button", { name: "Cancel editor" }));
await expectClosed();
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveFocus();
});
it("does not open or load the editor for a guest", () => {
mocks.currentUser = undefined;
renderProvider();
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("invalidates a pending open request across sign-out and same-user sign-in", async () => {
let resolveEditorLoad!: (module: { default: typeof MockMemoEditor }) => void;
mocks.loadMemoEditor.mockReturnValue(
new Promise((resolve) => {
resolveEditorLoad = resolve;
}),
);
const view = renderProvider();
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
expect(mocks.loadMemoEditor).toHaveBeenCalledOnce();
mocks.currentUser = undefined;
view.rerender(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
mocks.currentUser = { name: "users/test" };
view.rerender(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
await act(async () => {
resolveEditorLoad({ default: MockMemoEditor });
});
expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument();
});
it("does not resurrect an open composer after the same user signs out and back in", async () => {
const view = renderProvider();
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
await screen.findByRole("dialog", DIALOG);
mocks.currentUser = undefined;
view.rerender(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
await expectClosed();
mocks.currentUser = { name: "users/test" };
view.rerender(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument();
});
it("does not assign a global Compose shortcut", () => {
renderProvider(<span />);
fireEvent.keyDown(window, { key: "m", shiftKey: true, metaKey: true });
expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";

View file

@ -1,4 +1,3 @@
import { create } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { pairAppleLivePhotoFiles } from "@/components/MemoEditor/hooks";
import { splitInlineLocalFiles } from "@/components/MemoEditor/hooks/useInlineImageUpload";

View file

@ -39,11 +39,7 @@ describe("managed attachment Markdown", () => {
});
it("uses the first duplicate reference definition like Goldmark", () => {
const content = [
"![photo][asset]",
"[asset]: /file/attachments/first-image",
"[asset]: /file/attachments/second-image",
].join("\n\n");
const content = ["![photo][asset]", "[asset]: /file/attachments/first-image", "[asset]: /file/attachments/second-image"].join("\n\n");
expect(Array.from(extractManagedAttachmentUIDs(content))).toEqual(["first-image"]);
});

View file

@ -19,7 +19,9 @@ describe("uncheckAllTasks", () => {
it("unchecks every checked task while preserving source formatting", () => {
const markdown = ["Intro", "- [x] first", "* [X] second", " + [ ] nested", "1. [x] ordered", "Outro"].join("\n");
expect(uncheckAllTasks(markdown)).toBe(["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"));
expect(uncheckAllTasks(markdown)).toBe(
["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"),
);
});
it("returns the original string when no checkbox markers need changing", () => {

View file

@ -15,9 +15,7 @@ describe("media loading", () => {
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 { container } = render(<AudioAttachmentItem filename="recording.mp3" sourceUrl="/recording.mp3" mimeType="audio/mpeg" />);
const audio = container.querySelector("audio");
expect(audio).not.toHaveAttribute("src");

View file

@ -1,16 +1,10 @@
import { create } from "@bufbuild/protobuf";
import { create, type MessageInitShape } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { estimateMemoCardHeight } from "@/components/PagedMemoList/memoCardHeight";
import { type Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import {
type Memo,
MemoRelation_MemoSchema,
MemoRelation_Type,
MemoRelationSchema,
MemoSchema,
} from "@/types/proto/api/v1/memo_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const buildAttachment = (overrides: Partial<Attachment>) =>
const buildAttachment = (overrides: MessageInitShape<typeof AttachmentSchema>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",
@ -25,7 +19,7 @@ const buildCommentRelation = (memoName: string, index: number) =>
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
});
const buildMemo = (overrides: Partial<Memo> = {}) =>
const buildMemo = (overrides: MessageInitShape<typeof MemoSchema> = {}) =>
create(MemoSchema, {
name: "memos/main",
content: "hello",

View file

@ -36,10 +36,7 @@ describe("useInfiniteMemoComments", () => {
const { result } = renderHook(() => useInfiniteMemoComments("memos/parent", { pageSize: 2 }), { wrapper });
await waitFor(() => expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2"]));
expect(listMemoComments).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }),
);
expect(listMemoComments).toHaveBeenNthCalledWith(1, expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }));
expect(result.current.hasNextPage).toBe(true);
await act(async () => {

View file

@ -90,9 +90,7 @@ describe("memo content lazy renderers", () => {
});
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('<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");
});

View file

@ -1,4 +1,4 @@
import { create } from "@bufbuild/protobuf";
import { create, type MessageInitShape } from "@bufbuild/protobuf";
import { toBlob } from "html-to-image";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@ -10,14 +10,14 @@ import {
getMemoShareRenderWidth,
} from "@/components/MemoActionMenu/memoShareImage";
import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel";
import { type Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
vi.mock("html-to-image", () => ({
toBlob: vi.fn(),
}));
const buildMemo = (overrides: Partial<Memo> = {}) =>
const buildMemo = (overrides: MessageInitShape<typeof MemoSchema> = {}) =>
create(MemoSchema, {
name: "memos/test",
content: "hello",
@ -26,7 +26,7 @@ const buildMemo = (overrides: Partial<Memo> = {}) =>
...overrides,
});
const buildAttachment = (overrides: Partial<Attachment>) =>
const buildAttachment = (overrides: MessageInitShape<typeof AttachmentSchema>) =>
create(AttachmentSchema, {
name: "attachments/test",
filename: "test.bin",

View file

@ -91,9 +91,7 @@ describe("memo views", () => {
filters: [{ factor: "displayTime", value: "2026-08-02" }],
includePinned: false,
}),
).toBe(
`created_ts >= timestamp(${Math.floor(start.getTime() / 1000)}) && created_ts < timestamp(${Math.floor(end.getTime() / 1000)})`,
);
).toBe(`created_ts >= timestamp(${Math.floor(start.getTime() / 1000)}) && created_ts < timestamp(${Math.floor(end.getTime() / 1000)})`);
});
it("ignores invalid display-time filter values", () => {

View file

@ -9,6 +9,7 @@ class IntersectionObserverMock implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
readonly scrollMargin = "";
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
intersectionCallback = callback;

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
import { pickTileSprite, TILE_SPRITES } from "@/components/Placeholder/tileSprites";
describe("TILE_SPRITES integrity", () => {
it("registers 32px by 32px sprite strips with animation-specific frame counts", () => {

View file

@ -2,14 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MentionResolutionProvider,
useResolvedMentionUsernames,
useResolvedUser,
} from "@/components/MemoContent/MentionResolutionContext";
import { MentionResolutionProvider, useResolvedMentionUsernames, useResolvedUser } from "@/components/MemoContent/MentionResolutionContext";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import { memoKeys } from "@/hooks/useMemoQueries";
import { useMemoViews, useUser, userKeys, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
import { useMemoViews, userKeys, useUser, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";

View file

@ -0,0 +1,80 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import RootLayout from "@/layouts/RootLayout";
vi.mock("@/components/AppSidebar", () => ({
default: () => <aside data-testid="desktop-sidebar">Desktop sidebar</aside>,
MobileAppHeader: () => <header data-testid="mobile-header">Mobile header</header>,
MobileAppSidebar: () => <aside data-testid="mobile-sidebar">Mobile sidebar</aside>,
QuickFindDialog: () => <div data-testid="quick-find">Quick Find</div>,
SIDEBAR_WIDTH_VAR: "--app-sidebar-width",
SidebarResizeHandle: () => <div data-testid="resize-handle" />,
useSidebarWidth: () => ({ width: 256, minWidth: 192, maxWidth: 384, setWidth: vi.fn() }),
}));
// GlobalMemoEditorContext is deliberately NOT mocked: the real provider calls
// useAppSidebar(), which throws unless RootLayout nests it inside
// AppSidebarProvider. That nesting is the only thing here that can break.
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isUserSettingsInitialized: true }),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ profile: { instanceUrl: "https://example.com", demo: true } }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test" }),
}));
vi.mock("@/hooks/useMediaQuery", () => ({
default: () => true,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const SHELL_TEST_IDS = ["desktop-sidebar", "mobile-sidebar", "mobile-header", "quick-find"];
const RouteState = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<div>
<output data-testid="route">{location.pathname}</output>
<button type="button" onClick={() => navigate("/inbox")}>
Open inbox
</button>
</div>
);
};
describe("RootLayout global editor shell", () => {
it("mounts the composer provider inside the sidebar provider and keeps the shell across routes", () => {
render(
<MemoryRouter initialEntries={["/attachments"]}>
<Routes>
<Route element={<RootLayout />}>
<Route path="attachments" element={<RouteState />} />
<Route path="inbox" element={<RouteState />} />
</Route>
</Routes>
</MemoryRouter>,
);
for (const testId of SHELL_TEST_IDS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}
// The composer stays closed until something calls openEditor.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open inbox" }));
expect(screen.getByTestId("route")).toHaveTextContent("/inbox");
for (const testId of SHELL_TEST_IDS) {
expect(screen.getByTestId(testId)).toBeInTheDocument();
}
});
});

View file

@ -1,13 +1,8 @@
import { isValidElement } from "react";
import type { RouteObject } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { routeConfig, ROUTES } from "@/router";
import {
RequireAuthRoute,
RequireFullInitializationRoute,
RequireGuestRoute,
RequireInstanceInitializationRoute,
} from "@/router/guards";
import { ROUTES, routeConfig } from "@/router";
import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards";
// Walk the nested route config and find the first route with the given path,
// starting from the provided roots. Returns undefined if nothing matches.
@ -70,6 +65,10 @@ describe("router configuration", () => {
expect(hasAncestorOfType(routeConfig, ROUTES.ABOUT, RequireInstanceInitializationRoute)).toBe(true);
});
it("does not register a dedicated new memo route", () => {
expect(findByPath(routeConfig, "/new")).toBeUndefined();
});
it("leaves memo feeds available for early queries", () => {
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, "memos/:uid", "memos/shares/:token", "u/:username"]) {
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);

View file

@ -50,9 +50,9 @@ describe("scroll restoration", () => {
});
it("resets new routes and restores history entries", async () => {
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((xOrOptions, y) => {
scrollY = typeof xOrOptions === "number" ? (y ?? 0) : (xOrOptions.top ?? 0);
});
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(((xOrOptions?: number | ScrollToOptions, y?: number) => {
scrollY = typeof xOrOptions === "number" ? (y ?? 0) : (xOrOptions?.top ?? 0);
}) as typeof window.scrollTo);
const router = createMemoryRouter(
[
{

View file

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import { HashIcon } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import SidebarRow, { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
@ -26,7 +27,7 @@ describe("sidebar row grammar", () => {
});
it("gives a nav row the shared row box", () => {
render(<SidebarRow icon={() => null} label="Tasks" />);
render(<SidebarRow icon={HashIcon} label="Tasks" />);
expect(screen.getByRole("button", { name: "Tasks" })).toHaveClass(...boxClasses);
});

View file

@ -1,12 +1,7 @@
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
import { describe, expect, it } from "vitest";
import { findTagMetadata, mergeTagCounts } from "@/lib/tag";
import {
type UserSetting_TagMetadata,
type UserSetting_TagsSetting,
UserSetting_TagsSettingSchema,
UserStatsSchema,
} from "@/types/proto/api/v1/user_service_pb";
import { UserSetting_TagMetadataSchema, UserSetting_TagsSettingSchema, UserStatsSchema } from "@/types/proto/api/v1/user_service_pb";
describe("exact tag keys", () => {
it("aggregates names that collide with Object prototype properties", () => {
@ -51,13 +46,13 @@ describe("exact tag keys", () => {
expect(Object.keys(created.tags).sort()).toEqual(["__proto__", "constructor", "normal"]);
expect(Object.keys(decoded.tags).sort()).toEqual(["__proto__", "constructor", "normal"]);
expect(decoded.tags.normal.blurContent).toBe(false);
expect(decoded.tags.constructor.blurContent).toBe(true);
expect(decoded.tags["constructor"].blurContent).toBe(true);
expect(decoded.tags.__proto__.blurContent).toBe(true);
});
it("does not treat inherited properties as exact metadata", () => {
const metadata = { blurContent: true } as UserSetting_TagMetadata;
const setting = { tags: { ".*": metadata } } as UserSetting_TagsSetting;
const metadata = create(UserSetting_TagMetadataSchema, { blurContent: true });
const setting = create(UserSetting_TagsSettingSchema, { tags: { ".*": metadata } });
expect(findTagMetadata("toString", setting)).toBe(metadata);
});

View file

@ -42,11 +42,7 @@ describe("useMemoInit", () => {
filename: "image.png",
type: "image/png",
});
cacheService.saveNow(
cacheService.key("users/steven", "restored-draft"),
"![image](/file/attachments/image-one)",
[attachment],
);
cacheService.saveNow(cacheService.key("users/steven", "restored-draft"), "![image](/file/attachments/image-one)", [attachment]);
render(
<EditorProvider>

View file

@ -10,6 +10,7 @@ describe("<VideoPoster>", () => {
readonly root = null;
readonly rootMargin = "200px 0px";
readonly thresholds = [0];
readonly scrollMargin = "";
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
@ -84,13 +85,7 @@ describe("<VideoPoster>", () => {
});
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"
/>,
);
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();

View file

@ -20,5 +20,5 @@
"@/*": ["./src/*"]
}
},
"include": ["./src"]
"include": ["./src", "./tests"]
}