diff --git a/CLAUDE.md b/CLAUDE.md index 053cabd41..a46a17ba6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,16 @@ Memos is a self-hosted knowledge management platform with a Go backend and React - Each driver has its own migration files in `store/db/{driver}/migration/` - Schema version tracked in `instance_setting` table (key: `bb.general.version`) -**Why MobX for frontend state?** -- Simpler than Redux for this application's needs -- Stores in `web/src/store/` handle global state (user, memos, editor, dialogs) +**Why React Query + Context for frontend state?** +- **Server state** (memos, users, attachments) managed by React Query (TanStack Query v5) + - Automatic caching, deduplication, and background refetching + - Hooks in `web/src/hooks/useMemoQueries.ts`, `useUserQueries.ts`, `useAttachmentQueries.ts` +- **Client state** (UI preferences, filters) managed by React Context + - ViewContext (`web/src/contexts/ViewContext.tsx`) - layout, sort order + - MemoFilterContext (`web/src/contexts/MemoFilterContext.tsx`) - filter state +- **Legacy MobX** still present in some components (gradual migration in progress) + - Stores in `web/src/store/` used by unmigrated components + - Both systems coexist during transition period ## Critical Development Commands @@ -32,7 +39,7 @@ golangci-lint run # Lint **Frontend:** ```bash -cd web && pnpm dev # Start dev server +cd web && pnpm dev # Start dev server (React Query devtools at bottom-left) cd web && pnpm lint:fix # Lint and fix cd web && pnpm release # Build and copy to backend ``` @@ -42,6 +49,42 @@ cd web && pnpm release # Build and copy to backend cd proto && buf generate # Regenerate Go + TypeScript from .proto ``` +## Frontend State Management + +**Using React Query (Server State):** +```typescript +// Fetch memos +import { useMemos, useMemo } from "@/hooks/useMemoQueries"; +const { data: memos, isLoading } = useMemos({ filter }); +const { data: memo } = useMemo(memoName); + +// Mutations +import { useCreateMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; +const { mutate: createMemo } = useCreateMemo(); +const { mutate: updateMemo } = useUpdateMemo(); +``` + +**Using Context (Client State):** +```typescript +// View preferences +import { useView } from "@/contexts/ViewContext"; +const { layout, setLayout, orderByTimeAsc, toggleSortOrder } = useView(); + +// Filters +import { useMemoFilter } from "@/contexts/MemoFilterContext"; +const { filter, updateFilter } = useMemoFilter(); +``` + +**React Query DevTools:** +- Available in dev mode at bottom-left corner +- Inspect query cache, mutations, and refetch behavior +- Query keys organized by resource: `memoKeys`, `userKeys`, `attachmentKeys` + +**Migration Status:** +- ✅ Migrated: Home, MemoDetail, UserProfile, Inboxes pages +- 🔄 In Progress: Remaining pages and components (gradual migration) +- See `web/scripts/migration-guide.md` for migration patterns + ## Key Workflows **Modifying APIs:** @@ -66,7 +109,7 @@ cd proto && buf generate # Regenerate Go + TypeScript from . **Entry point:** `cmd/memos/` starts the server **API layer:** `server/router/api/v1/` implements gRPC services **Data layer:** `store/` handles all persistence -**Frontend:** `web/src/` React app with MobX state management +**Frontend:** `web/src/` React app with React Query + Context (migrating from MobX) ## Testing Expectations diff --git a/web/package.json b/web/package.json index 02eb3028b..5fc12473f 100644 --- a/web/package.json +++ b/web/package.json @@ -30,6 +30,8 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.12", + "@tanstack/react-query-devtools": "^5.91.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", @@ -45,8 +47,6 @@ "mermaid": "^11.12.1", "micromark-extension-gfm": "^3.0.0", "mime": "^4.1.0", - "mobx": "^6.15.0", - "mobx-react-lite": "^4.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-force-graph-2d": "^1.29.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index be03d3f35..d224b1431 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -68,6 +68,12 @@ importers: '@tailwindcss/vite': specifier: ^4.1.17 version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)) + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.12(react@18.3.1) + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -113,12 +119,6 @@ importers: mime: specifier: ^4.1.0 version: 4.1.0 - mobx: - specifier: ^6.15.0 - version: 6.15.0 - mobx-react-lite: - specifier: ^4.1.1 - version: 4.1.1(mobx@6.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1342,6 +1342,23 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.12': + resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + + '@tanstack/query-devtools@5.91.1': + resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + + '@tanstack/react-query-devtools@5.91.1': + resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.10 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.12': + resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + peerDependencies: + react: ^18 || ^19 + '@tweenjs/tween.js@25.0.0': resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} @@ -2403,22 +2420,6 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mobx-react-lite@4.1.1: - resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==} - peerDependencies: - mobx: ^6.9.0 - react: ^16.8.0 || ^17 || ^18 || ^19 - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - - mobx@6.15.0: - resolution: {integrity: sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2883,11 +2884,6 @@ packages: '@types/react': optional: true - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -3990,6 +3986,21 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1) + '@tanstack/query-core@5.90.12': {} + + '@tanstack/query-devtools@5.91.1': {} + + '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.91.1 + '@tanstack/react-query': 5.90.12(react@18.3.1) + react: 18.3.1 + + '@tanstack/react-query@5.90.12(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.90.12 + react: 18.3.1 + '@tweenjs/tween.js@25.0.0': {} '@types/babel__core@7.20.5': @@ -5376,16 +5387,6 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - mobx-react-lite@4.1.1(mobx@6.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - mobx: 6.15.0 - react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) - optionalDependencies: - react-dom: 18.3.1(react@18.3.1) - - mobx@6.15.0: {} - ms@2.1.3: {} nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -5921,10 +5922,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 - use-sync-external-store@1.6.0(react@18.3.1): - dependencies: - react: 18.3.1 - uuid@11.1.0: {} vfile-location@5.0.3: diff --git a/web/src/App.tsx b/web/src/App.tsx index fac304da5..d8e0f949b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,16 +1,15 @@ -import { observer } from "mobx-react-lite"; import { useEffect } from "react"; import { Outlet } from "react-router-dom"; +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"; -import { instanceStore } from "./store"; import { cleanupExpiredOAuthState } from "./utils/oauth"; -const App = observer(() => { +const App = () => { const navigateTo = useNavigateTo(); - const instanceProfile = instanceStore.state.profile; - const instanceGeneralSetting = instanceStore.state.generalSetting; + const { profile: instanceProfile, generalSetting: instanceGeneralSetting } = useInstance(); // Apply user preferences reactively useUserLocale(); @@ -21,12 +20,12 @@ const App = observer(() => { cleanupExpiredOAuthState(); }, []); - // Redirect to sign up page if no instance owner. + // Redirect to sign up page if no instance owner useEffect(() => { if (!instanceProfile.owner) { navigateTo("/auth/signup"); } - }, [instanceProfile.owner]); + }, [instanceProfile.owner, navigateTo]); useEffect(() => { if (instanceGeneralSetting.additionalStyle) { @@ -45,7 +44,7 @@ const App = observer(() => { } }, [instanceGeneralSetting.additionalScript]); - // Dynamic update metadata with customized profile. + // Dynamic update metadata with customized profile useEffect(() => { if (!instanceGeneralSetting.customProfile) { return; @@ -56,7 +55,11 @@ const App = observer(() => { link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp"; }, [instanceGeneralSetting.customProfile]); - return ; -}); + return ( + + + + ); +}; export default App; diff --git a/web/src/components/ActivityCalendar/ActivityCalendar.tsx b/web/src/components/ActivityCalendar/ActivityCalendar.tsx index 64469f431..f1a642736 100644 --- a/web/src/components/ActivityCalendar/ActivityCalendar.tsx +++ b/web/src/components/ActivityCalendar/ActivityCalendar.tsx @@ -1,65 +1,63 @@ import dayjs from "dayjs"; -import { observer } from "mobx-react-lite"; import { memo, useMemo } from "react"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { instanceStore } from "@/store"; +import { useInstance } from "@/contexts/InstanceContext"; import type { ActivityCalendarProps } from "@/types/statistics"; import { useTranslate } from "@/utils/i18n"; import { CalendarCell } from "./CalendarCell"; import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared"; import { useCalendarMatrix } from "./useCalendarMatrix"; -export const ActivityCalendar = memo( - observer((props: ActivityCalendarProps) => { - const t = useTranslate(); - const { month, selectedDate, data, onClick } = props; - const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset; +export const ActivityCalendar = memo((props: ActivityCalendarProps) => { + const t = useTranslate(); + const { month, selectedDate, data, onClick } = props; + const { generalSetting } = useInstance(); + const weekStartDayOffset = generalSetting.weekStartDayOffset; - const today = useTodayDate(); - const weekDaysRaw = useWeekdayLabels(); - const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]); + const today = useTodayDate(); + const weekDaysRaw = useWeekdayLabels(); + const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]); - const { weeks, weekDays, maxCount } = useCalendarMatrix({ - month, - data, - weekDays: weekDaysRaw, - weekStartDayOffset, - today, - selectedDate: selectedDateFormatted, - }); + const { weeks, weekDays, maxCount } = useCalendarMatrix({ + month, + data, + weekDays: weekDaysRaw, + weekStartDayOffset, + today, + selectedDate: selectedDateFormatted, + }); - return ( - -
-
- {weekDays.map((label, index) => ( -
- {label} -
- ))} -
- -
- {weeks.map((week, weekIndex) => - week.days.map((day, dayIndex) => { - const tooltipText = getTooltipText(day.count, day.date, t); - - return ( - - ); - }), - )} -
+ return ( + +
+
+ {weekDays.map((label, index) => ( +
+ {label} +
+ ))}
- - ); - }), -); + +
+ {weeks.map((week, weekIndex) => + week.days.map((day, dayIndex) => { + const tooltipText = getTooltipText(day.count, day.date, t); + + return ( + + ); + }), + )} +
+
+
+ ); +}); ActivityCalendar.displayName = "ActivityCalendar"; diff --git a/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx b/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx index bf070a22a..7e808561d 100644 --- a/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx +++ b/web/src/components/ActivityCalendar/CompactMonthCalendar.tsx @@ -1,7 +1,6 @@ -import { observer } from "mobx-react-lite"; import { memo } from "react"; +import { useInstance } from "@/contexts/InstanceContext"; import { cn } from "@/lib/utils"; -import { instanceStore } from "@/store"; import { useTranslate } from "@/utils/i18n"; import { CalendarCell } from "./CalendarCell"; import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants"; @@ -9,48 +8,47 @@ import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared"; import type { CompactMonthCalendarProps } from "./types"; import { useCalendarMatrix } from "./useCalendarMatrix"; -export const CompactMonthCalendar = memo( - observer((props: CompactMonthCalendarProps) => { - const { month, data, maxCount, size = "default", onClick } = props; - const t = useTranslate(); +export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => { + const { month, data, maxCount, size = "default", onClick } = props; + const t = useTranslate(); + const { generalSetting } = useInstance(); - const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset; + const weekStartDayOffset = generalSetting.weekStartDayOffset; - const today = useTodayDate(); - const weekDays = useWeekdayLabels(); + const today = useTodayDate(); + const weekDays = useWeekdayLabels(); - const { weeks } = useCalendarMatrix({ - month, - data, - weekDays, - weekStartDayOffset, - today, - selectedDate: "", - }); + const { weeks } = useCalendarMatrix({ + month, + data, + weekDays, + weekStartDayOffset, + today, + selectedDate: "", + }); - const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE; + const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE; - return ( -
- {weeks.map((week, weekIndex) => - week.days.map((day, dayIndex) => { - const tooltipText = getTooltipText(day.count, day.date, t); + return ( +
+ {weeks.map((week, weekIndex) => + week.days.map((day, dayIndex) => { + const tooltipText = getTooltipText(day.count, day.date, t); - return ( - - ); - }), - )} -
- ); - }), -); + return ( + + ); + }), + )} +
+ ); +}); CompactMonthCalendar.displayName = "CompactMonthCalendar"; diff --git a/web/src/components/AuthFooter.tsx b/web/src/components/AuthFooter.tsx index f68f05ed0..f5015e40c 100644 --- a/web/src/components/AuthFooter.tsx +++ b/web/src/components/AuthFooter.tsx @@ -1,4 +1,3 @@ -import { observer } from "mobx-react-lite"; import { useTranslation } from "react-i18next"; import i18n from "@/i18n"; import { cn } from "@/lib/utils"; @@ -10,7 +9,7 @@ interface Props { className?: string; } -const AuthFooter = observer(({ className }: Props) => { +const AuthFooter = ({ className }: Props) => { const { i18n: i18nInstance } = useTranslation(); const currentLocale = i18nInstance.language as Locale; const currentTheme = getInitialTheme(); @@ -29,6 +28,6 @@ const AuthFooter = observer(({ className }: Props) => {
); -}); +}; export default AuthFooter; diff --git a/web/src/components/ChangeMemberPasswordDialog.tsx b/web/src/components/ChangeMemberPasswordDialog.tsx index 70f57881c..385696eee 100644 --- a/web/src/components/ChangeMemberPasswordDialog.tsx +++ b/web/src/components/ChangeMemberPasswordDialog.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { userStore } from "@/store"; +import { useUpdateUser } from "@/hooks/useUserQueries"; import { User } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -17,6 +17,7 @@ interface Props { function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) { const t = useTranslate(); + const { mutateAsync: updateUser } = useUpdateUser(); const [newPassword, setNewPassword] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState(""); @@ -49,13 +50,13 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro } try { - await userStore.updateUser( - { + await updateUser({ + user: { name: user.name, password: newPassword, }, - ["password"], - ); + updateMask: ["password"], + }); toast(t("message.password-changed")); onSuccess?.(); onOpenChange(false); diff --git a/web/src/components/CreateAccessTokenDialog.tsx b/web/src/components/CreateAccessTokenDialog.tsx index c6cfd5b31..6e0cad3b6 100644 --- a/web/src/components/CreateAccessTokenDialog.tsx +++ b/web/src/components/CreateAccessTokenDialog.tsx @@ -75,7 +75,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { try { requestState.setLoading(); const response = await userServiceClient.createPersonalAccessToken({ - parent: currentUser.name, + parent: currentUser?.name, description: state.description, expiresInDays: state.expiration, }); diff --git a/web/src/components/CreateShortcutDialog.tsx b/web/src/components/CreateShortcutDialog.tsx index 42787e7ec..cc0623bed 100644 --- a/web/src/components/CreateShortcutDialog.tsx +++ b/web/src/components/CreateShortcutDialog.tsx @@ -8,9 +8,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { shortcutServiceClient } from "@/connect"; +import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; -import { userStore } from "@/store"; import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -24,6 +24,7 @@ interface Props { function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) { const t = useTranslate(); const user = useCurrentUser(); + const { refetchSettings } = useAuth(); const [shortcut, setShortcut] = useState( create(ShortcutSchema, { name: initialShortcut?.name || "", @@ -66,7 +67,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o requestState.setLoading(); if (isCreating) { await shortcutServiceClient.createShortcut({ - parent: user.name, + parent: user?.name, shortcut: { name: "", // Will be set by server title: shortcut.title, @@ -85,7 +86,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o toast.success("Update shortcut successfully"); } // Refresh shortcuts. - await userStore.fetchUserSettings(); + await refetchSettings(); requestState.setFinish(); onSuccess?.(); onOpenChange(false); diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 9c1fd3cc3..39c711fd9 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -1,15 +1,14 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { create } from "@bufbuild/protobuf"; +import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt"; import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react"; -import { observer } from "mobx-react-lite"; import { useState } from "react"; import toast from "react-hot-toast"; import UserAvatar from "@/components/UserAvatar"; -import { activityServiceClient } from "@/connect"; +import { activityServiceClient, memoServiceClient, userServiceClient } from "@/connect"; +import { activityNamePrefix } from "@/helpers/resource-names"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useNavigateTo from "@/hooks/useNavigateTo"; import { cn } from "@/lib/utils"; -import { memoStore, userStore } from "@/store"; -import { activityNamePrefix } from "@/store/common"; import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -18,7 +17,7 @@ interface Props { notification: UserNotification; } -const MemoCommentMessage = observer(({ notification }: Props) => { +function MemoCommentMessage({ notification }: Props) { const t = useTranslate(); const navigateTo = useNavigateTo(); const [relatedMemo, setRelatedMemo] = useState(undefined); @@ -39,18 +38,20 @@ const MemoCommentMessage = observer(({ notification }: Props) => { if (activity.payload?.payload?.case === "memoComment") { const memoCommentPayload = activity.payload.payload.value; - const memo = await memoStore.getOrFetchMemoByName(memoCommentPayload.relatedMemo, { - skipStore: true, + const memo = await memoServiceClient.getMemo({ + name: memoCommentPayload.relatedMemo, }); setRelatedMemo(memo); // Fetch the comment memo - const comment = await memoStore.getOrFetchMemoByName(memoCommentPayload.memo, { - skipStore: true, + const comment = await memoServiceClient.getMemo({ + name: memoCommentPayload.memo, }); setCommentMemo(comment); - const sender = await userStore.getOrFetchUser(notification.sender); + const sender = await userServiceClient.getUser({ + name: notification.sender, + }); setSender(sender); setInitialized(true); } @@ -73,20 +74,22 @@ const MemoCommentMessage = observer(({ notification }: Props) => { }; const handleArchiveMessage = async (silence = false) => { - await userStore.updateNotification( - { + await userServiceClient.updateUserNotification({ + notification: { name: notification.name, status: UserNotification_Status.ARCHIVED, }, - ["status"], - ); + updateMask: create(FieldMaskSchema, { paths: ["status"] }), + }); if (!silence) { toast.success(t("message.archived-successfully")); } }; const handleDeleteMessage = async () => { - await userStore.deleteNotification(notification.name); + await userServiceClient.deleteUserNotification({ + name: notification.name, + }); toast.success(t("message.deleted-successfully")); }; @@ -222,6 +225,6 @@ const MemoCommentMessage = observer(({ notification }: Props) => { ); -}); +} export default MemoCommentMessage; diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index 361e0a106..afe2c0ce2 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -11,7 +11,6 @@ import { SquareCheckIcon, TrashIcon, } from "lucide-react"; -import { observer } from "mobx-react-lite"; import { useState } from "react"; import ConfirmDialog from "@/components/ConfirmDialog"; import { Button } from "@/components/ui/button"; @@ -30,7 +29,7 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation"; import { useMemoActionHandlers } from "./hooks"; import type { MemoActionMenuProps } from "./types"; -const MemoActionMenu = observer((props: MemoActionMenuProps) => { +const MemoActionMenu = (props: MemoActionMenuProps) => { const { memo, readonly } = props; const t = useTranslate(); @@ -157,6 +156,6 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { /> ); -}); +}; export default MemoActionMenu; diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index 3f92da8f4..53c5c80e8 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -1,9 +1,12 @@ +import { useQueryClient } from "@tanstack/react-query"; import copy from "copy-to-clipboard"; import { useCallback } from "react"; import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; +import { useInstance } from "@/contexts/InstanceContext"; +import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { instanceStore, memoStore, userStore } from "@/store"; +import { userKeys } from "@/hooks/useUserQueries"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -20,25 +23,30 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe const t = useTranslate(); const location = useLocation(); const navigateTo = useNavigateTo(); + const queryClient = useQueryClient(); + const { profile } = useInstance(); + const { mutateAsync: updateMemo } = useUpdateMemo(); + const { mutateAsync: deleteMemo } = useDeleteMemo(); const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); const memoUpdatedCallback = useCallback(() => { - userStore.setStatsStateId(); - }, []); + // Invalidate user stats to trigger refetch + queryClient.invalidateQueries({ queryKey: userKeys.stats() }); + }, [queryClient]); const handleTogglePinMemoBtnClick = useCallback(async () => { try { - await memoStore.updateMemo( - { + await updateMemo({ + update: { name: memo.name, pinned: !memo.pinned, }, - ["pinned"], - ); + updateMask: ["pinned"], + }); } catch { // do nothing } - }, [memo.name, memo.pinned]); + }, [memo.name, memo.pinned, updateMemo]); const handleEditMemoClick = useCallback(() => { onEdit?.(); @@ -49,13 +57,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully"); try { - await memoStore.updateMemo( - { + await updateMemo({ + update: { name: memo.name, state, }, - ["state"], - ); + updateMask: ["state"], + }); toast.success(message); } catch (error: unknown) { const err = error as { details?: string }; @@ -68,16 +76,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); } memoUpdatedCallback(); - }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]); + }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); const handleCopyLink = useCallback(() => { - let host = instanceStore.state.profile.instanceUrl; + let host = profile.instanceUrl; if (host === "") { host = window.location.origin; } copy(`${host}/${memo.name}`); toast.success(t("message.succeed-copy-link")); - }, [memo.name, t]); + }, [memo.name, t, profile.instanceUrl]); const handleCopyContent = useCallback(() => { copy(memo.content); @@ -89,13 +97,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe }, [setDeleteDialogOpen]); const confirmDeleteMemo = useCallback(async () => { - await memoStore.deleteMemo(memo.name); + await deleteMemo(memo.name); toast.success(t("message.deleted-successfully")); if (isInMemoDetailPage) { navigateTo("/"); } memoUpdatedCallback(); - }, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]); + }, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo]); const handleRemoveCompletedTaskListItemsClick = useCallback(() => { setRemoveTasksDialogOpen(true); @@ -103,16 +111,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe const confirmRemoveCompletedTaskListItems = useCallback(async () => { const newContent = removeCompletedTasks(memo.content); - await memoStore.updateMemo( - { + await updateMemo({ + update: { name: memo.name, content: newContent, }, - ["content"], - ); + updateMask: ["content"], + }); toast.success(t("message.remove-completed-task-list-items-successfully")); memoUpdatedCallback(); - }, [memo.name, memo.content, t, memoUpdatedCallback]); + }, [memo.name, memo.content, t, memoUpdatedCallback, updateMemo]); return { handleTogglePinMemoBtnClick, diff --git a/web/src/components/MemoContent/CodeBlock.tsx b/web/src/components/MemoContent/CodeBlock.tsx index d94afad77..86bb4a1ef 100644 --- a/web/src/components/MemoContent/CodeBlock.tsx +++ b/web/src/components/MemoContent/CodeBlock.tsx @@ -1,10 +1,9 @@ import copy from "copy-to-clipboard"; import hljs from "highlight.js"; import { CheckIcon, CopyIcon } from "lucide-react"; -import { observer } from "mobx-react-lite"; import { useEffect, useMemo, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; import { cn } from "@/lib/utils"; -import { userStore } from "@/store"; import { getThemeWithFallback, resolveTheme } from "@/utils/theme"; import { MermaidBlock } from "./MermaidBlock"; import { extractCodeContent, extractLanguage } from "./utils"; @@ -14,7 +13,8 @@ interface CodeBlockProps { className?: string; } -export const CodeBlock = observer(({ children, className, ...props }: CodeBlockProps) => { +export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => { + const { userGeneralSetting } = useAuth(); const [copied, setCopied] = useState(false); const codeElement = children as React.ReactElement; @@ -33,7 +33,7 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP ); } - const theme = getThemeWithFallback(userStore.state.userGeneralSetting?.theme); + const theme = getThemeWithFallback(userGeneralSetting?.theme); const resolvedTheme = resolveTheme(theme); const isDarkTheme = resolvedTheme.includes("dark"); @@ -131,4 +131,4 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP ); -}); +}; diff --git a/web/src/components/MemoContent/MermaidBlock.tsx b/web/src/components/MemoContent/MermaidBlock.tsx index 83946dd13..48ec20bb1 100644 --- a/web/src/components/MemoContent/MermaidBlock.tsx +++ b/web/src/components/MemoContent/MermaidBlock.tsx @@ -1,8 +1,7 @@ import mermaid from "mermaid"; -import { observer } from "mobx-react-lite"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useAuth } from "@/contexts/AuthContext"; import { cn } from "@/lib/utils"; -import { userStore } from "@/store"; import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme"; import { extractCodeContent } from "./utils"; @@ -15,7 +14,8 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => { return appTheme === "default-dark" ? "dark" : "default"; }; -export const MermaidBlock = observer(({ children, className }: MermaidBlockProps) => { +export const MermaidBlock = ({ children, className }: MermaidBlockProps) => { + const { userGeneralSetting } = useAuth(); const containerRef = useRef(null); const [svg, setSvg] = useState(""); const [error, setError] = useState(""); @@ -23,9 +23,9 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps const codeContent = extractCodeContent(children); - // Get theme preference (reactive via MobX observer) + // Get theme preference (reactive via AuthContext) // Falls back to localStorage or system preference if no user setting - const themePreference = getThemeWithFallback(userStore.state.userGeneralSetting?.theme); + const themePreference = getThemeWithFallback(userGeneralSetting?.theme); // Resolve theme to actual value (handles "system" theme + system theme changes) const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]); @@ -90,4 +90,4 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps dangerouslySetInnerHTML={{ __html: svg }} /> ); -}); +}; diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 92a112ceb..3a17c0f6f 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -1,10 +1,9 @@ import { useContext } from "react"; import { useLocation } from "react-router-dom"; +import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import useNavigateTo from "@/hooks/useNavigateTo"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; -import { memoFilterStore } from "@/store"; -import { MemoFilter, stringifyFilters } from "@/store/memoFilter"; import { MemoContentContext } from "./MemoContentContext"; interface TagProps extends React.HTMLAttributes { @@ -17,6 +16,7 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa const context = useContext(MemoContentContext); const location = useLocation(); const navigateTo = useNavigateTo(); + const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext(); const tag = dataTag || ""; @@ -37,13 +37,13 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa return; } - const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag); + const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag); if (isActive) { - memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag); + removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag); } else { // Remove all existing tag filters first, then add the new one - memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch"); - memoFilterStore.addFilter({ + removeFilter((f: MemoFilter) => f.factor === "tagSearch"); + addFilter({ factor: "tagSearch", value: tag, }); diff --git a/web/src/components/MemoContent/TaskListItem.tsx b/web/src/components/MemoContent/TaskListItem.tsx index db007fbe9..3acdc7775 100644 --- a/web/src/components/MemoContent/TaskListItem.tsx +++ b/web/src/components/MemoContent/TaskListItem.tsx @@ -1,6 +1,8 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useContext, useRef } from "react"; import { Checkbox } from "@/components/ui/checkbox"; -import { memoStore } from "@/store"; +import { memoKeys, useUpdateMemo } from "@/hooks/useMemoQueries"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { toggleTaskAtIndex } from "@/utils/markdown-manipulation"; import { MemoContentContext } from "./MemoContentContext"; @@ -12,6 +14,8 @@ interface TaskListItemProps extends React.InputHTMLAttributes export const TaskListItem: React.FC = ({ checked, ...props }) => { const context = useContext(MemoContentContext); const checkboxRef = useRef(null); + const queryClient = useQueryClient(); + const { mutate: updateMemo } = useUpdateMemo(); const handleChange = async (newChecked: boolean) => { // Don't update if readonly or no memo context @@ -49,19 +53,19 @@ export const TaskListItem: React.FC = ({ checked, ...props }) } // Update memo content using the string manipulation utility - const memo = memoStore.getMemoByName(context.memoName); + const memo = queryClient.getQueryData(memoKeys.detail(context.memoName)); if (!memo) { return; } const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked); - await memoStore.updateMemo( - { + updateMemo({ + update: { name: memo.name, content: newContent, }, - ["content"], - ); + updateMask: ["content"], + }); }; // Override the disabled prop from remark-gfm (which defaults to true) diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 42d95fb2c..382a7dd40 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -1,4 +1,4 @@ -import { observer } from "mobx-react-lite"; +import { useQueryClient } from "@tanstack/react-query"; import { memo } from "react"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; @@ -8,8 +8,9 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import useCurrentUser from "@/hooks/useCurrentUser"; +import { memoKeys } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; -import { memoStore } from "@/store"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; @@ -24,16 +25,17 @@ import { Tag } from "./Tag"; import { TaskListItem } from "./TaskListItem"; import type { MemoContentProps } from "./types"; -const MemoContent = observer((props: MemoContentProps) => { +const MemoContent = (props: MemoContentProps) => { const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props; const t = useTranslate(); const currentUser = useCurrentUser(); + const queryClient = useQueryClient(); const { containerRef: memoContentContainerRef, mode: showCompactMode, toggle: toggleCompactMode, } = useCompactMode(Boolean(props.compact)); - const memo = memoName ? memoStore.getMemoByName(memoName) : null; + const memo = memoName ? queryClient.getQueryData(memoKeys.detail(memoName)) : null; const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser)); const contextValue = { @@ -94,6 +96,6 @@ const MemoContent = observer((props: MemoContentProps) => { ); -}); +}; export default memo(MemoContent); diff --git a/web/src/components/MemoDisplaySettingMenu.tsx b/web/src/components/MemoDisplaySettingMenu.tsx index e906913fc..d6293d513 100644 --- a/web/src/components/MemoDisplaySettingMenu.tsx +++ b/web/src/components/MemoDisplaySettingMenu.tsx @@ -1,8 +1,7 @@ import { Settings2Icon } from "lucide-react"; -import { observer } from "mobx-react-lite"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useView } from "@/contexts/ViewContext"; import { cn } from "@/lib/utils"; -import { viewStore } from "@/store"; import { useTranslate } from "@/utils/i18n"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; @@ -10,9 +9,10 @@ interface Props { className?: string; } -const MemoDisplaySettingMenu = observer(({ className }: Props) => { +function MemoDisplaySettingMenu({ className }: Props) { const t = useTranslate(); - const isApplying = viewStore.state.orderByTimeAsc !== false || viewStore.state.layout !== "LIST"; + const { orderByTimeAsc, layout, toggleSortOrder, setLayout } = useView(); + const isApplying = orderByTimeAsc !== false || layout !== "LIST"; return ( @@ -24,12 +24,12 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
{t("memo.direction")} - viewStore.state.setPartial({ - layout: value as "LIST" | "MASONRY", - }) - } - > +