chore: scope memo providers to authenticated routes

This commit is contained in:
boojack 2026-07-30 23:42:37 +08:00
parent b895a462da
commit 571e0a3ff6
17 changed files with 339 additions and 125 deletions

View file

@ -1,8 +1,6 @@
import { useEffect } from "react";
import { Outlet, ScrollRestoration } from "react-router-dom";
import { GlobalMemoEditorProvider } from "./contexts/GlobalMemoEditorContext";
import { useInstance } from "./contexts/InstanceContext";
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
import useNavigateTo from "./hooks/useNavigateTo";
import { useUserLocale } from "./hooks/useUserLocale";
import { useUserTheme } from "./hooks/useUserTheme";
@ -61,11 +59,7 @@ const App = () => {
return (
<>
<MemoFilterProvider>
<GlobalMemoEditorProvider>
<Outlet />
</GlobalMemoEditorProvider>
</MemoFilterProvider>
<Outlet />
<ScrollRestoration />
</>
);

View file

@ -33,7 +33,9 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward
onSubmitRef.current = onSubmit;
const placeholderRef = useRef(placeholder);
const listenersRef = useRef(new Set<() => void>());
const { data: tagData } = useTagCounts();
// A user can only author their own memos. Reuse the current-user stats query
// instead of fetching and aggregating every user's tags for autocomplete.
const { data: tagData } = useTagCounts(true);
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
const tagsRef = useRef(tags);
tagsRef.current = tags;

View file

@ -1,6 +1,8 @@
import { EyeIcon } from "lucide-react";
import ClampedSection from "@/components/ClampedSection";
import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata";
import { isReferenceRelation } from "@/components/MemoMetadata/Relation/relationHelpers";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent";
@ -12,10 +14,17 @@ import type { MemoBodyProps } from "../types";
const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
const t = useTranslate();
return (
<div className="absolute inset-0 z-10 pt-4 flex items-center justify-center" onClick={onClick}>
<div className="rounded-lg border border-border bg-card px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:bg-accent hover:text-foreground">
<div className="absolute inset-0 z-10 flex items-center justify-center pt-4">
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer rounded-lg bg-card px-3 text-xs text-foreground shadow-sm hover:-translate-y-0.5 hover:border-ring/40 hover:bg-accent hover:text-accent-foreground hover:shadow-md active:translate-y-0 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={onClick}
>
<EyeIcon className="h-3.5 w-3.5" />
{t("memo.click-to-show-sensitive-content")}
</div>
</Button>
</div>
);
};

View file

@ -3,7 +3,6 @@ import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEff
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
@ -113,8 +112,7 @@ function useAutoFetchWhenNotScrollable({
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
const { isUserSettingsInitialized } = useAuth();
const { filters } = useMemoFilterContext();
const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
@ -153,10 +151,9 @@ const PagedMemoList = (props: Props) => {
{ enabled: props.enabled ?? true },
);
// Queries can start as soon as routing is unlocked, but memo content stays
// hidden until settings that control its presentation have settled.
const isDisplayPending = isLoading || !authInitialized || !instanceInitialized;
// Only show the spinner once loading exceeds the delay, so fast loads don't flash it.
// Tag settings decide whether sensitive memo content must be blurred. Keep that
// privacy boundary, but do not wait for unrelated shortcuts or instance settings.
const isDisplayPending = isLoading || !isUserSettingsInitialized;
const showLoader = useDelayedFlag(isDisplayPending, LOADING_INDICATOR_DELAY_MS);
// Flatten pages into a single array of memos
@ -198,28 +195,30 @@ const PagedMemoList = (props: Props) => {
// 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.
const firstMemo = sortedMemoList[0];
const displayMemoList = isDisplayPending ? [] : sortedMemoList;
const firstMemo = displayMemoList[0];
const priorityKey = newMemoName && firstMemo?.name === newMemoName ? getMemoKey(firstMemo) : undefined;
// Stable reference so MentionResolutionProvider's memo (keyed on the array) actually holds.
const contents = useMemo(() => sortedMemoList.map((memo) => memo.content), [sortedMemoList]);
const contents = useMemo(() => displayMemoList.map((memo) => memo.content), [displayMemoList]);
const userNames = useMemo(
() =>
Array.from(
new Set(
sortedMemoList.flatMap((memo) => [
displayMemoList.flatMap((memo) => [
...(props.showCreator ? [memo.creator] : []),
...(memo.reactions ?? []).map((reaction) => reaction.creator),
]),
),
),
[props.showCreator, sortedMemoList],
[props.showCreator, displayMemoList],
);
const emptyPlaceholder =
!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 ? (
!isDisplayPending && !isFetchingNextPage && !hasNextPage && displayMemoList.length === 0 ? (
<Placeholder variant="empty" message={t("message.no-data")} className="w-full" />
) : null;
const initialLoader = isDisplayPending && showLoader ? <Loader /> : null;
// Column one is the action column: the composer and any active filters head it, and the
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
@ -227,10 +226,11 @@ const PagedMemoList = (props: Props) => {
// grid's x-spacing exactly.
const hasFilters = filters.length > 0;
const gridLeading =
leadingContent || hasFilters || emptyPlaceholder ? (
leadingContent || hasFilters || initialLoader || emptyPlaceholder ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{leadingContent}
<MemoFilters />
{initialLoader}
{emptyPlaceholder}
</div>
) : undefined;
@ -239,7 +239,7 @@ const PagedMemoList = (props: Props) => {
const footer = (
<>
{isFetchingNextPage && <Loader />}
{!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && (
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
<BackToTop />
</div>
@ -251,15 +251,10 @@ const PagedMemoList = (props: Props) => {
<MentionResolutionProvider contents={contents} userNames={userNames}>
<div ref={layoutMeasureRef} className="w-full">
<div className={cn("flex flex-col justify-start w-full mx-auto", useGrid ? "max-w-none" : "max-w-2xl")}>
{/* During initial load, show the spinner only after the delay; render nothing before then to avoid a flash. */}
{isDisplayPending ? (
showLoader ? (
<Loader />
) : null
) : useGrid ? (
{useGrid ? (
<>
<ColumnGrid
items={sortedMemoList}
items={displayMemoList}
getKey={getMemoKey}
renderItem={(memo) => props.renderer(memo, { compact: effectiveCompact })}
estimateHeight={estimateMemoCardHeight}
@ -268,15 +263,16 @@ const PagedMemoList = (props: Props) => {
maxColumns={maxColumns}
maxColumnWidth={MAX_COLUMN_WIDTH}
/>
{footer}
{!isDisplayPending && footer}
</>
) : (
<>
{leadingContent}
<MemoFilters className="mb-2" />
{sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{initialLoader}
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{emptyPlaceholder}
{footer}
{!isDisplayPending && footer}
</>
)}
</div>

View file

@ -19,6 +19,8 @@ interface AuthState {
shortcuts: Shortcut[];
/** Authentication identity has settled, while user settings may still be loading. */
isIdentityInitialized: boolean;
/** User settings that affect memo presentation are safe to consume. */
isUserSettingsInitialized: boolean;
isInitialized: boolean;
isLoading: boolean;
}
@ -40,6 +42,7 @@ const UNAUTHENTICATED_STATE: AuthState = {
userTagsSetting: undefined,
shortcuts: [],
isIdentityInitialized: true,
isUserSettingsInitialized: true,
isInitialized: true,
isLoading: false,
};
@ -53,24 +56,44 @@ export function AuthProvider({ children }: { children: ReactNode }) {
userTagsSetting: undefined,
shortcuts: [],
isIdentityInitialized: false,
isUserSettingsInitialized: false,
isInitialized: false,
isLoading: true,
});
const fetchUserSettings = useCallback(async (userName: string) => {
const [{ settings }, { shortcuts }] = await Promise.all([
userServiceClient.listUserSettings({ parent: userName }),
const userSettingsPromise = userServiceClient.listUserSettings({ parent: userName }).then(({ settings }) => {
const generalSetting = settings.find((s) => s.value.case === "generalSetting");
const webhooksSetting = settings.find((s) => s.value.case === "webhooksSetting");
const tagsSetting = settings.find((s) => s.value.case === "tagsSetting");
const userSettings = {
userGeneralSetting: generalSetting?.value.case === "generalSetting" ? generalSetting.value.value : undefined,
userWebhooksSetting: webhooksSetting?.value.case === "webhooksSetting" ? webhooksSetting.value.value : undefined,
userTagsSetting: tagsSetting?.value.case === "tagsSetting" ? tagsSetting.value.value : undefined,
};
// Tag settings control sensitive-content blurring. Publish them as soon
// as this request settles instead of waiting for unrelated shortcuts.
setState((prev) =>
prev.currentUser?.name === userName
? {
...prev,
...userSettings,
isUserSettingsInitialized: true,
}
: prev,
);
return userSettings;
});
const [userSettings, { shortcuts }] = await Promise.all([
userSettingsPromise,
shortcutServiceClient.listShortcuts({ parent: userName }),
]);
const generalSetting = settings.find((s) => s.value.case === "generalSetting");
const webhooksSetting = settings.find((s) => s.value.case === "webhooksSetting");
const tagsSetting = settings.find((s) => s.value.case === "tagsSetting");
return {
userGeneralSetting: generalSetting?.value.case === "generalSetting" ? generalSetting.value.value : undefined,
userWebhooksSetting: webhooksSetting?.value.case === "webhooksSetting" ? webhooksSetting.value.value : undefined,
userTagsSetting: tagsSetting?.value.case === "tagsSetting" ? tagsSetting.value.value : undefined,
...userSettings,
shortcuts,
};
}, []);
@ -79,7 +102,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// `initialize` also runs after sign-in, when the previous unauthenticated
// state is already marked initialized. Reset the full-readiness flag so
// consumers cannot render with the new identity and stale/default settings.
setState((prev) => ({ ...prev, isInitialized: false, isLoading: true }));
setState((prev) => ({ ...prev, isUserSettingsInitialized: false, isInitialized: false, isLoading: true }));
// Try to get or refresh the access token.
// This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage
@ -111,7 +134,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Publish the verified identity immediately so route modules and their
// data queries can start while display-sensitive settings are loading.
// Memo rendering remains gated on the full `isInitialized` state.
setState((prev) => ({
...prev,
currentUser,
@ -127,6 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
currentUser,
...settings,
isIdentityInitialized: true,
isUserSettingsInitialized: true,
isInitialized: true,
isLoading: false,
});

View file

@ -1,6 +1,7 @@
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import type { MemoEditorProps } from "@/components/MemoEditor/types";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useTranslate } from "@/utils/i18n";
interface GlobalMemoEditorContextValue {
@ -8,29 +9,36 @@ interface GlobalMemoEditorContextValue {
openEditor: () => void;
}
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue>({
isOpen: false,
openEditor: () => {},
});
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) {
const t = useTranslate();
const currentUserName = useCurrentUser()?.name;
const [isOpen, setIsOpen] = useState(false);
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
const openEditor = useCallback(() => {
if (!currentUserName) {
return;
}
setIsOpen(true);
void loadMemoEditor()
.then(({ default: MemoEditor }) => setEditorComponent(() => MemoEditor))
.catch(() => setIsOpen(false));
}, []);
}, [currentUserName]);
const closeEditor = useCallback(() => setIsOpen(false), []);
const value = useMemo(() => ({ isOpen, openEditor }), [isOpen, openEditor]);
const editorIsOpen = !!currentUserName && isOpen;
const value = useMemo(() => ({ isOpen: editorIsOpen, openEditor }), [editorIsOpen, openEditor]);
useEffect(() => {
closeEditor();
}, [currentUserName, closeEditor]);
return (
<GlobalMemoEditorContext.Provider value={value}>
{children}
{isOpen && EditorComponent && (
{editorIsOpen && EditorComponent && (
<EditorComponent
autoFocus
initialFocusMode
@ -46,5 +54,9 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode })
}
export function useGlobalMemoEditor() {
return useContext(GlobalMemoEditorContext);
const context = useContext(GlobalMemoEditorContext);
if (!context) {
throw new Error("useGlobalMemoEditor must be used within GlobalMemoEditorProvider");
}
return context;
}

View file

@ -1,5 +1,5 @@
import { uniqBy } from "lodash-es";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
export type FilterFactor =
@ -126,26 +126,36 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) {
const hasFilter = useCallback((filter: MemoFilter) => filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter)), [filters]);
const hasActiveFilters = filters.length > 0 || shortcut !== undefined;
return (
<MemoFilterContext.Provider
value={{
filters,
shortcut,
hasActiveFilters,
getFiltersByFactor,
setFilters,
addFilter,
removeFilter,
removeFiltersByFactor,
clearAllFilters,
setShortcut,
hasFilter,
}}
>
{children}
</MemoFilterContext.Provider>
const value = useMemo(
() => ({
filters,
shortcut,
hasActiveFilters,
getFiltersByFactor,
setFilters,
addFilter,
removeFilter,
removeFiltersByFactor,
clearAllFilters,
setShortcut,
hasFilter,
}),
[
filters,
shortcut,
hasActiveFilters,
getFiltersByFactor,
setFilters,
addFilter,
removeFilter,
removeFiltersByFactor,
clearAllFilters,
setShortcut,
hasFilter,
],
);
return <MemoFilterContext.Provider value={value}>{children}</MemoFilterContext.Provider>;
}
export function useMemoFilterContext() {

View file

@ -1,8 +1,9 @@
import { useEffect, useRef } from "react";
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
import Navigation from "@/components/Navigation";
import { GlobalMemoEditorProvider } from "@/contexts/GlobalMemoEditorContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
@ -27,7 +28,7 @@ const DemoBanner = () => {
);
};
const RootLayout = () => {
const RootLayoutContent = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const sm = useMediaQuery("sm");
@ -57,24 +58,32 @@ const RootLayout = () => {
}
return (
<div className="w-full min-h-full flex flex-row justify-center items-start sm:pl-16">
{sm && (
<div
className={cn(
"group flex flex-col justify-start items-start fixed top-0 left-0 select-none h-full bg-sidebar",
"w-16 px-2",
"border-r border-border",
)}
>
<Navigation className="py-4 md:pt-6" collapsed={true} />
</div>
)}
<main className="w-full h-auto grow shrink flex flex-col justify-start items-center">
{profile.demo && <DemoBanner />}
<Outlet />
</main>
</div>
<GlobalMemoEditorProvider>
<div className="w-full min-h-full flex flex-row justify-center items-start sm:pl-16">
{sm && (
<div
className={cn(
"group flex flex-col justify-start items-start fixed top-0 left-0 select-none h-full bg-sidebar",
"w-16 px-2",
"border-r border-border",
)}
>
<Navigation className="py-4 md:pt-6" collapsed={true} />
</div>
)}
<main className="w-full h-auto grow shrink flex flex-col justify-start items-center">
{profile.demo && <DemoBanner />}
<Outlet />
</main>
</div>
</GlobalMemoEditorProvider>
);
};
const RootLayout = () => (
<MemoFilterProvider>
<RootLayoutContent />
</MemoFilterProvider>
);
export default RootLayout;

View file

@ -50,7 +50,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
// Route loading and feed requests only need the verified identity and the
// instance profile. Display-sensitive settings continue in the background;
// PagedMemoList keeps memo content hidden until they have settled.
// PagedMemoList keeps memo content hidden until privacy settings have settled.
if (!isIdentityInitialized || !isProfileInitialized) {
return null;
}

View file

@ -3,6 +3,7 @@ import MemoEditor from "@/components/MemoEditor";
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { NewMemoProvider } from "@/contexts/NewMemoContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
@ -14,6 +15,7 @@ import { useTranslate } from "@/utils/i18n";
const Home = () => {
const user = useCurrentUser();
const t = useTranslate();
const { isUserSettingsInitialized } = useAuth();
const { filters } = useMemoFilterContext();
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
@ -38,14 +40,18 @@ const Home = () => {
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
renderLeading={({ useGrid }) => (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
)}
renderLeading={({ useGrid }) => {
if (!isUserSettingsInitialized) return null;
return (
<MemoEditor
className={useGrid ? undefined : "mb-2"}
cacheKey="home-memo-editor"
placeholder={t("editor.any-thoughts")}
defaultCreateTime={defaultCreateTime}
/>
);
}}
/>
</NewMemoProvider>
</div>

View file

@ -32,10 +32,11 @@ vi.mock("@/connect", () => ({
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
const Probe = () => {
const { currentUser, initialize, isInitialized } = useAuth();
const { currentUser, initialize, isInitialized, isUserSettingsInitialized } = useAuth();
return (
<div>
<span data-testid="initialized">{isInitialized ? "yes" : "no"}</span>
<span data-testid="user-settings-initialized">{isUserSettingsInitialized ? "yes" : "no"}</span>
<span data-testid="user">{currentUser?.name ?? "none"}</span>
<button type="button" onClick={() => void initialize()}>
initialize
@ -80,8 +81,12 @@ describe("AuthProvider initialization", () => {
fireEvent.click(screen.getByRole("button", { name: "initialize" }));
await waitFor(() => expect(screen.getByTestId("user")).toHaveTextContent("users/alice"));
expect(screen.getByTestId("initialized")).toHaveTextContent("no");
expect(screen.getByTestId("user-settings-initialized")).toHaveTextContent("no");
resolveSettings({ settings: [] });
await waitFor(() => expect(screen.getByTestId("user-settings-initialized")).toHaveTextContent("yes"));
expect(screen.getByTestId("initialized")).toHaveTextContent("no");
resolveShortcuts({ shortcuts: [] });
await waitFor(() => expect(screen.getByTestId("initialized")).toHaveTextContent("yes"));
});

View file

@ -4,11 +4,30 @@ import { describe, expect, it, vi } from "vitest";
import Editor from "@/components/MemoEditor/Editor";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
const queries = vi.hoisted(() => ({
useTagCounts: vi.fn(() => ({ data: {} })),
}));
vi.mock("@/hooks/useUserQueries", () => ({
useTagCounts: () => ({ data: {} }),
useTagCounts: queries.useTagCounts,
}));
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()}
/>,
);
expect(queries.useTagCounts).toHaveBeenCalledWith(true);
});
it("loads markdown and serializes it back verbatim", () => {
const ref = createRef<EditorController>();
render(

View file

@ -1,7 +1,12 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { loadMemoEditor } from "@/components/MemoEditor/loader";
import { GlobalMemoEditorProvider, useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
const authState = vi.hoisted(() => ({
currentUser: undefined as { name: string } | undefined,
}));
vi.mock("@/components/MemoEditor/loader", () => ({
loadMemoEditor: vi.fn(async () => ({
default: ({ initialFocusMode, onFocusModeExit }: { initialFocusMode?: boolean; onFocusModeExit?: () => void }) => (
@ -14,6 +19,10 @@ vi.mock("@/components/MemoEditor/loader", () => ({
})),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => authState.currentUser,
}));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
function Trigger() {
@ -27,6 +36,8 @@ function Trigger() {
describe("GlobalMemoEditorProvider", () => {
it("opens the shared editor in focus mode and closes when focus mode exits", async () => {
authState.currentUser = { name: "users/test" };
render(
<GlobalMemoEditorProvider>
<Trigger />
@ -41,4 +52,19 @@ describe("GlobalMemoEditorProvider", () => {
fireEvent.click(screen.getByRole("button", { name: "Exit focus mode" }));
expect(screen.queryByTestId("global-editor")).not.toBeInTheDocument();
});
it("does not load or open the editor for an unauthenticated user", () => {
authState.currentUser = undefined;
render(
<GlobalMemoEditorProvider>
<Trigger />
</GlobalMemoEditorProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
expect(loadMemoEditor).not.toHaveBeenCalled();
expect(screen.queryByTestId("global-editor")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,61 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Home from "@/pages/Home";
vi.mock("@/components/MemoEditor", () => ({
default: () => <div data-testid="memo-editor" />,
}));
vi.mock("@/components/MemoView", () => ({
default: () => <div data-testid="memo-view" />,
}));
vi.mock("@/components/PagedMemoList", () => ({
default: ({
renderer,
renderLeading,
}: {
renderer: (memo: { name: string }, options: { compact: boolean }) => React.ReactNode;
renderLeading: (options: { useGrid: boolean }) => React.ReactNode;
}) => (
<>
{renderLeading({ useGrid: false })}
{renderer({ name: "memos/1" }, { compact: false })}
</>
),
getMemoKey: (memo: { name: string }) => memo.name,
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isUserSettingsInitialized: true }),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
}));
vi.mock("@/contexts/NewMemoContext", () => ({
NewMemoProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/hooks", () => ({
useMemoFilters: () => "",
useMemoSorting: () => ({ listSort: undefined, orderBy: "create_time desc" }),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/1" }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("<Home>", () => {
it("renders the editor and memo cards synchronously without blank placeholders", () => {
render(<Home />);
expect(screen.getByTestId("memo-editor")).toBeInTheDocument();
expect(screen.getByTestId("memo-view")).toBeInTheDocument();
});
});

View file

@ -11,6 +11,9 @@ const mockState = vi.hoisted(() => ({
attachments: [],
reactions: [],
},
blurred: false,
showBlurredContent: false,
toggleBlurVisibility: vi.fn(),
}));
vi.mock("@/utils/i18n", () => ({
@ -46,12 +49,12 @@ vi.mock("@/components/MemoView/MemoViewContext", () => ({
useMemoViewContext: () => ({
memo: mockState.memo,
parentPage: "",
showBlurredContent: false,
blurred: false,
showBlurredContent: mockState.showBlurredContent,
blurred: mockState.blurred,
readonly: false,
openEditor: vi.fn(),
openPreview: vi.fn(),
toggleBlurVisibility: vi.fn(),
toggleBlurVisibility: mockState.toggleBlurVisibility,
}),
}));
@ -64,6 +67,9 @@ const createMemo = (content: string) => ({
});
afterEach(() => {
mockState.blurred = false;
mockState.showBlurredContent = false;
mockState.toggleBlurVisibility.mockReset();
vi.restoreAllMocks();
});
@ -97,4 +103,17 @@ describe("<MemoBody /> compact body clamp", () => {
expect(screen.queryByRole("button", { name: /memo\.show-more/ })).toBeNull();
});
it("renders the sensitive-content action as a button", () => {
mockState.blurred = true;
mockState.memo = createMemo("sensitive content");
render(<MemoBody compact={false} />);
const revealButton = screen.getByRole("button", { name: "memo.click-to-show-sensitive-content" });
expect(revealButton).toHaveClass("cursor-pointer");
fireEvent.click(revealButton);
expect(mockState.toggleBlurVisibility).toHaveBeenCalledOnce();
});
});

View file

@ -11,7 +11,7 @@ const feed = vi.hoisted(() => ({
isLoading: false,
fetchNextPage: vi.fn(async () => undefined),
}));
const readiness = vi.hoisted(() => ({ auth: true, instance: true }));
const readiness = vi.hoisted(() => ({ userSettings: true }));
vi.mock("@/hooks/useMemoQueries", () => ({
useInfiniteMemos: () => ({
@ -28,11 +28,7 @@ vi.mock("@/contexts/MemoFilterContext", () => ({
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isInitialized: readiness.auth }),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: readiness.instance }),
useAuth: () => ({ isUserSettingsInitialized: readiness.userSettings }),
}));
vi.mock("@/contexts/ViewContext", () => ({
@ -71,13 +67,12 @@ describe("<PagedMemoList>", () => {
feed.hasNextPage = false;
feed.isLoading = false;
feed.fetchNextPage.mockClear();
readiness.auth = true;
readiness.instance = true;
readiness.userSettings = true;
});
it("does not render fetched memo content before display settings settle", () => {
it("keeps fetched memo content hidden until privacy settings settle", () => {
feed.memos = [memo];
readiness.auth = false;
readiness.userSettings = false;
const renderer = vi.fn((m: Memo) => <div key={m.name}>{m.content}</div>);
renderList(renderer);
@ -86,12 +81,22 @@ describe("<PagedMemoList>", () => {
expect(screen.queryByText("hello")).not.toBeInTheDocument();
});
it("does not auto-fetch more pages while display settings are pending", async () => {
it("renders fetched memo content once privacy settings settle", () => {
feed.memos = [memo];
const renderer = vi.fn((m: Memo) => <div key={m.name}>{m.content}</div>);
renderList(renderer);
expect(renderer).toHaveBeenCalled();
expect(screen.getByText("hello")).toBeInTheDocument();
});
it("does not auto-fetch more pages before privacy settings settle", async () => {
vi.useFakeTimers();
try {
feed.memos = [memo];
feed.hasNextPage = true;
readiness.auth = false;
readiness.userSettings = false;
renderList();
await act(async () => vi.advanceTimersByTimeAsync(1000));
@ -102,6 +107,21 @@ describe("<PagedMemoList>", () => {
}
});
it("auto-fetches more pages once memo data is ready", async () => {
vi.useFakeTimers();
try {
feed.memos = [memo];
feed.hasNextPage = true;
renderList();
await act(async () => vi.advanceTimersByTimeAsync(200));
expect(feed.fetchNextPage).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("delays the initial loading spinner to avoid flashing on fast loads", async () => {
vi.useFakeTimers();
try {
@ -118,6 +138,14 @@ describe("<PagedMemoList>", () => {
}
});
it("keeps route-owned leading content visible while memos load", () => {
feed.isLoading = true;
renderList(undefined, { leading: <div data-testid="leading-content" /> });
expect(screen.getByTestId("leading-content")).toBeInTheDocument();
});
it("uses the tile sprite Placeholder for the empty state", () => {
renderList();

View file

@ -1,5 +1,4 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App";
@ -12,10 +11,6 @@ vi.mock("@/contexts/InstanceContext", () => ({
}),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
MemoFilterProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/hooks/useNavigateTo", () => ({ default: () => vi.fn() }));
vi.mock("@/hooks/useUserLocale", () => ({ useUserLocale: vi.fn() }));
vi.mock("@/hooks/useUserTheme", () => ({ useUserTheme: vi.fn() }));