chore: scope memo providers to authenticated routes
This commit is contained in:
parent
b895a462da
commit
571e0a3ff6
17 changed files with 339 additions and 125 deletions
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
61
web/tests/home-loading-boundary.test.tsx
Normal file
61
web/tests/home-loading-boundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue