From 571e0a3ff6eb0da8f7832bb15d5553a932641b63 Mon Sep 17 00:00:00 2001 From: boojack Date: Thu, 30 Jul 2026 23:42:37 +0800 Subject: [PATCH] chore: scope memo providers to authenticated routes --- web/src/App.tsx | 8 +-- .../components/MemoEditor/Editor/index.tsx | 4 +- .../MemoView/components/MemoBody.tsx | 15 ++++- .../PagedMemoList/PagedMemoList.tsx | 44 ++++++------- web/src/contexts/AuthContext.tsx | 45 ++++++++++---- web/src/contexts/GlobalMemoEditorContext.tsx | 30 ++++++--- web/src/contexts/MemoFilterContext.tsx | 50 +++++++++------ web/src/layouts/RootLayout.tsx | 47 ++++++++------ web/src/main.tsx | 2 +- web/src/pages/Home.tsx | 22 ++++--- .../auth-context-initialization.test.tsx | 7 ++- web/tests/editor.test.tsx | 21 ++++++- web/tests/global-memo-editor.test.tsx | 26 ++++++++ web/tests/home-loading-boundary.test.tsx | 61 +++++++++++++++++++ web/tests/memo-body-compact.test.tsx | 25 +++++++- web/tests/paged-memo-list.test.tsx | 52 ++++++++++++---- web/tests/scroll-restoration.test.tsx | 5 -- 17 files changed, 339 insertions(+), 125 deletions(-) create mode 100644 web/tests/home-loading-boundary.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index dce63182..21003fb6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( <> - - - - - + ); diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 132fa84a..54359e96 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -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; diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx index 5a703f04..c004d2c0 100644 --- a/web/src/components/MemoView/components/MemoBody.tsx +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -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 ( -
-
+
+
+
); }; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 0a0f1ab0..9ef79fd8 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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 ? ( ) : null; + const initialLoader = isDisplayPending && showLoader ? : 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 ? (
{leadingContent} + {initialLoader} {emptyPlaceholder}
) : undefined; @@ -239,7 +239,7 @@ const PagedMemoList = (props: Props) => { const footer = ( <> {isFetchingNextPage && } - {!isFetchingNextPage && (hasNextPage || sortedMemoList.length > 0) && ( + {!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && (
@@ -251,15 +251,10 @@ const PagedMemoList = (props: Props) => {
- {/* During initial load, show the spinner only after the delay; render nothing before then to avoid a flash. */} - {isDisplayPending ? ( - showLoader ? ( - - ) : null - ) : useGrid ? ( + {useGrid ? ( <> 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} - {sortedMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} + {initialLoader} + {displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))} {emptyPlaceholder} - {footer} + {!isDisplayPending && footer} )}
diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index a38120ab..7443668a 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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, }); diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx index aac75df0..6c6d3908 100644 --- a/web/src/contexts/GlobalMemoEditorContext.tsx +++ b/web/src/contexts/GlobalMemoEditorContext.tsx @@ -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({ - isOpen: false, - openEditor: () => {}, -}); +const GlobalMemoEditorContext = createContext(null); export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) { const t = useTranslate(); + const currentUserName = useCurrentUser()?.name; const [isOpen, setIsOpen] = useState(false); const [EditorComponent, setEditorComponent] = useState>(); 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 ( {children} - {isOpen && EditorComponent && ( + {editorIsOpen && EditorComponent && ( filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter)), [filters]); const hasActiveFilters = filters.length > 0 || shortcut !== undefined; - - return ( - - {children} - + 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 {children}; } export function useMemoFilterContext() { diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 25e52ef8..1f853ffa 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -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 ( -
- {sm && ( -
- -
- )} -
- {profile.demo && } - -
-
+ +
+ {sm && ( +
+ +
+ )} +
+ {profile.demo && } + +
+
+
); }; +const RootLayout = () => ( + + + +); + export default RootLayout; diff --git a/web/src/main.tsx b/web/src/main.tsx index 258dbf1b..e6eeadf0 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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; } diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 5ff97301..89290785 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -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 }) => ( - - )} + renderLeading={({ useGrid }) => { + if (!isUserSettingsInitialized) return null; + + return ( + + ); + }} />
diff --git a/web/tests/auth-context-initialization.test.tsx b/web/tests/auth-context-initialization.test.tsx index c7cb053e..69b8dc25 100644 --- a/web/tests/auth-context-initialization.test.tsx +++ b/web/tests/auth-context-initialization.test.tsx @@ -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 (
{isInitialized ? "yes" : "no"} + {isUserSettingsInitialized ? "yes" : "no"} {currentUser?.name ?? "none"}