feat: react query migration (#5379)

This commit is contained in:
Johnny 2025-12-24 22:59:18 +08:00 committed by GitHub
parent 4109fe3245
commit f87f728b0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
110 changed files with 2300 additions and 2863 deletions

View File

@ -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

View File

@ -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",

View File

@ -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:

View File

@ -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 <Outlet />;
});
return (
<MemoFilterProvider>
<Outlet />
</MemoFilterProvider>
);
};
export default App;

View File

@ -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 (
<TooltipProvider>
<div className="w-full flex flex-col gap-0.5">
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
{weekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t);
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
/>
);
}),
)}
</div>
return (
<TooltipProvider>
<div className="w-full flex flex-col gap-0.5">
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
{weekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
{label}
</div>
))}
</div>
</TooltipProvider>
);
}),
);
<div className="grid grid-cols-7 gap-0.5">
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t);
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
/>
);
}),
)}
</div>
</div>
</TooltipProvider>
);
});
ActivityCalendar.displayName = "ActivityCalendar";

View File

@ -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 (
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t);
return (
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
{weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t);
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
size={size}
/>
);
}),
)}
</div>
);
}),
);
return (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
size={size}
/>
);
}),
)}
</div>
);
});
CompactMonthCalendar.displayName = "CompactMonthCalendar";

View File

@ -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) => {
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
</div>
);
});
};
export default AuthFooter;

View File

@ -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);

View File

@ -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,
});

View File

@ -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<Shortcut>(
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);

View File

@ -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<Memo | undefined>(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) => {
</div>
</div>
);
});
}
export default MemoCommentMessage;

View File

@ -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) => {
/>
</DropdownMenu>
);
});
};
export default MemoActionMenu;

View File

@ -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,

View File

@ -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
</div>
</pre>
);
});
};

View File

@ -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<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
@ -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 }}
/>
);
});
};

View File

@ -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<HTMLSpanElement> {
@ -17,6 +16,7 @@ export const Tag: React.FC<TagProps> = ({ "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<TagProps> = ({ "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,
});

View File

@ -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<HTMLInputElement>
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
const context = useContext(MemoContentContext);
const checkboxRef = useRef<HTMLButtonElement>(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<TaskListItemProps> = ({ checked, ...props })
}
// Update memo content using the string manipulation utility
const memo = memoStore.getMemoByName(context.memoName);
const memo = queryClient.getQueryData<Memo>(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)

View File

@ -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<Memo>(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) => {
</div>
</MemoContentContext.Provider>
);
});
};
export default memo(MemoContent);

View File

@ -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 (
<Popover>
@ -24,12 +24,12 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.direction")}</span>
<Select
value={viewStore.state.orderByTimeAsc.toString()}
onValueChange={(value) =>
viewStore.state.setPartial({
orderByTimeAsc: value === "true",
})
}
value={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm">
<SelectValue />
@ -42,14 +42,7 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("common.layout")}</span>
<Select
value={viewStore.state.layout}
onValueChange={(value) =>
viewStore.state.setPartial({
layout: value as "LIST" | "MASONRY",
})
}
>
<Select value={layout} onValueChange={(value) => setLayout(value as "LIST" | "MASONRY")}>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
@ -63,6 +56,6 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
</PopoverContent>
</Popover>
);
});
}
export default MemoDisplaySettingMenu;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import type { EditorRefActions } from ".";
import type { Command } from "./commands";
import { SuggestionsPopup } from "./SuggestionsPopup";
@ -10,7 +9,7 @@ interface SlashCommandsProps {
commands: Command[];
}
const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
@ -43,6 +42,6 @@ const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCom
)}
/>
);
});
};
export default SlashCommands;

View File

@ -1,7 +1,8 @@
import { observer } from "mobx-react-lite";
import { useMemo } from "react";
import { matchPath } from "react-router-dom";
import OverflowTip from "@/components/kit/OverflowTip";
import { userStore } from "@/store";
import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
@ -11,12 +12,16 @@ interface TagSuggestionsProps {
editorActions: React.ForwardedRef<EditorRefActions>;
}
const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => {
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
// On explore page, show all users' tags; otherwise show current user's tags
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
const sortedTags = useMemo(() => {
return Object.entries(userStore.state.tagCount)
return Object.entries(tagCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag);
}, [userStore.state.tagCount]);
}, [tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
@ -47,6 +52,4 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro
)}
/>
);
});
export default TagSuggestions;
}

View File

@ -1,4 +1,4 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import { editorCommands } from "./commands";
@ -48,99 +48,116 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
} = props;
const editorRef = useRef<HTMLTextAreaElement>(null);
const updateEditorHeight = useCallback(() => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
}
}, []);
const updateContent = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
}, [handleContentChangeCallback, updateEditorHeight]);
useEffect(() => {
if (editorRef.current && initialContent) {
editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent);
updateEditorHeight();
}
// Only run once on mount to set initial content
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateEditorHeight = () => {
if (editorRef.current) {
editorRef.current.style.height = "auto";
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
// Update editor when content is externally changed (e.g., reset after save)
useEffect(() => {
if (editorRef.current && editorRef.current.value !== initialContent) {
editorRef.current.value = initialContent;
updateEditorHeight();
}
};
}, [initialContent, updateEditorHeight]);
const updateContent = () => {
const editorActions: EditorRefActions = useMemo(
() => ({
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
if (editorRef.current) {
editorRef.current.scrollTop = editorRef.current.scrollHeight;
}
},
insertText: (content = "", prefix = "", suffix = "") => {
const editor = editorRef.current;
if (!editor) return;
const cursorPos = editor.selectionStart;
const endPos = editor.selectionEnd;
const prev = editor.value;
const actual = content || prev.slice(cursorPos, endPos);
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
editor.focus();
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
updateContent();
},
removeText: (start: number, length: number) => {
const editor = editorRef.current;
if (!editor) return;
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
editor.focus();
editor.selectionEnd = start;
updateContent();
},
setContent: (text: string) => {
const editor = editorRef.current;
if (editor) {
editor.value = text;
updateContent();
}
},
getContent: () => editorRef.current?.value ?? "",
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
getSelectedContent: () => {
const editor = editorRef.current;
if (!editor) return "";
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
},
setCursorPosition: (startPos: number, endPos?: number) => {
const endPosition = Number.isNaN(endPos) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, endPosition);
},
getCursorLineNumber: () => {
const editor = editorRef.current;
if (!editor) return 0;
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
return lines.length - 1;
},
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
setLine: (lineNumber: number, text: string) => {
const editor = editorRef.current;
if (!editor) return;
const lines = editor.value.split("\n");
lines[lineNumber] = text;
editor.value = lines.join("\n");
editor.focus();
updateContent();
},
}),
[updateContent],
);
useImperativeHandle(ref, () => editorActions, [editorActions]);
const handleEditorInput = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
}
};
const editorActions: EditorRefActions = {
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
editorRef.current && (editorRef.current.scrollTop = editorRef.current.scrollHeight);
},
insertText: (content = "", prefix = "", suffix = "") => {
const editor = editorRef.current;
if (!editor) return;
const cursorPos = editor.selectionStart;
const endPos = editor.selectionEnd;
const prev = editor.value;
const actual = content || prev.slice(cursorPos, endPos);
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
editor.focus();
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
updateContent();
},
removeText: (start: number, length: number) => {
const editor = editorRef.current;
if (!editor) return;
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
editor.focus();
editor.selectionEnd = start;
updateContent();
},
setContent: (text: string) => {
const editor = editorRef.current;
if (editor) {
editor.value = text;
updateContent();
}
},
getContent: () => editorRef.current?.value ?? "",
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
getSelectedContent: () => {
const editor = editorRef.current;
if (!editor) return "";
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
},
setCursorPosition: (startPos: number, endPos?: number) => {
const endPosition = isNaN(endPos as number) ? startPos : (endPos as number);
editorRef.current?.setSelectionRange(startPos, endPosition);
},
getCursorLineNumber: () => {
const editor = editorRef.current;
if (!editor) return 0;
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
return lines.length - 1;
},
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
setLine: (lineNumber: number, text: string) => {
const editor = editorRef.current;
if (!editor) return;
const lines = editor.value.split("\n");
lines[lineNumber] = text;
editor.value = lines.join("\n");
editor.focus();
updateContent();
},
};
useImperativeHandle(ref, () => editorActions, []);
const handleEditorInput = useCallback(() => {
handleContentChangeCallback(editorRef.current?.value ?? "");
updateEditorHeight();
}, []);
}, [handleContentChangeCallback, updateEditorHeight]);
// Auto-complete markdown lists when pressing Enter
useListCompletion({

View File

@ -1,7 +1,6 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react";
import type { LocalFile } from "@/components/memo-metadata";
import { Button } from "@/components/ui/button";
@ -30,7 +29,7 @@ interface Props {
onToggleFocusMode?: () => void;
}
const InsertMenu = observer((props: Props) => {
const InsertMenu = (props: Props) => {
const t = useTranslate();
const context = useContext(MemoEditorContext);
@ -221,6 +220,6 @@ const InsertMenu = observer((props: Props) => {
/>
</>
);
});
};
export default InsertMenu;

View File

@ -3,8 +3,8 @@ import { useState } from "react";
import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { extractUserIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractUserIdFromName } from "@/store/common";
import {
Memo,
MemoRelation,
@ -37,7 +37,7 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
setIsFetching(true);
try {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`];
if (searchText) {
conditions.push(`content.contains("${searchText}")`);
}

View File

@ -1,7 +1,8 @@
import { observer } from "mobx-react-lite";
import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef } from "react";
import { toast } from "react-hot-toast";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
@ -23,7 +24,7 @@ export interface MemoEditorProps {
onCancel?: () => void;
}
const MemoEditor = observer((props: MemoEditorProps) => {
const MemoEditor = (props: MemoEditorProps) => {
const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props;
return (
@ -40,7 +41,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
/>
</EditorProvider>
);
});
};
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className,
@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
onCancel,
}) => {
const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const editorRef = useRef<EditorRefActions>(null);
const { state, actions, dispatch } = useEditorContext();
@ -66,7 +68,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })),
memoName,
addLocalFiles: (files: typeof state.localFiles) => {
files.forEach((file) => dispatch(actions.addLocalFile(file)));
files.forEach((file) => {
dispatch(actions.addLocalFile(file));
});
},
removeLocalFile: (previewUrl: string) => dispatch(actions.removeLocalFile(previewUrl)),
localFiles: state.localFiles,
@ -75,10 +79,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
);
// Initialize editor (load memo or cache)
useMemoInit(editorRef, memoName, cacheKey, currentUser.name, autoFocus);
useMemoInit(editorRef, memoName, cacheKey, currentUser?.name ?? "", autoFocus);
// Auto-save content to localStorage
useAutoSave(state.content, currentUser.name, cacheKey);
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
// Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode);
@ -91,6 +95,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
useKeyboard(editorRef, { onSave: handleSave, onToggleFocusMode: handleToggleFocusMode });
async function handleSave() {
// Validate before saving
const { valid, reason } = validationService.canSave(state);
if (!valid) {
toast.error(reason || "Cannot save");
@ -108,19 +113,26 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
return;
}
// Clear cache on successful save
cacheService.clear(cacheService.key(currentUser.name, cacheKey));
// Clear localStorage cache on successful save
cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey));
// Reset editor state
// Invalidate React Query cache to refresh memo lists across the app
await Promise.all([
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }),
]);
// Reset editor state to initial values
dispatch(actions.reset());
// Notify parent
// Notify parent component of successful save
onConfirm?.(result.memoName);
toast.success("Saved successfully");
} catch (error) {
const message = errorService.handle(error, t);
toast.error(message);
const errorMessage = errorService.getErrorMessage(error);
toast.error(errorMessage);
console.error("Failed to save memo:", error);
} finally {
dispatch(actions.setLoading("saving", false));
}

View File

@ -1,27 +1,8 @@
import type { Translations } from "@/utils/i18n";
export type EditorErrorCode = "UPLOAD_FAILED" | "SAVE_FAILED" | "VALIDATION_FAILED" | "LOAD_FAILED";
export class EditorError extends Error {
constructor(
public code: EditorErrorCode,
public details?: unknown,
) {
super(`Editor error: ${code}`);
this.name = "EditorError";
}
}
export const errorService = {
handle(error: unknown, t: (key: Translations, params?: Record<string, any>) => string): string {
if (error instanceof EditorError) {
// Try to get localized error message
const key = `editor.error.${error.code.toLowerCase()}` as Translations;
return t(key, { details: error.details });
}
getErrorMessage(error: unknown): string {
// Handle ConnectError or errors with details property
if (error && typeof error === "object" && "details" in error) {
return (error as { details?: string }).details || "An unknown error occurred";
return (error as { details?: string }).details || "An error occurred";
}
if (error instanceof Error) {

View File

@ -1,12 +1,10 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
import { FieldMaskSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { memoServiceClient } from "@/connect";
import { memoStore } from "@/store";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorState } from "../state";
import { EditorError } from "./errorService";
import { uploadService } from "./uploadService";
function buildUpdateMask(
@ -73,91 +71,73 @@ export const memoService = {
parentMemoName?: string;
},
): Promise<{ memoName: string; hasChanges: boolean }> {
try {
// 1. Upload local files first
const newAttachments = await uploadService.uploadFiles(state.localFiles);
const allAttachments = [...state.metadata.attachments, ...newAttachments];
// 1. Upload local files first
const newAttachments = await uploadService.uploadFiles(state.localFiles);
const allAttachments = [...state.metadata.attachments, ...newAttachments];
// 2. Update existing memo
if (options.memoName) {
const prevMemo = await memoStore.getOrFetchMemoByName(options.memoName);
if (!prevMemo) {
throw new EditorError("SAVE_FAILED", "Memo not found");
}
// 2. Update existing memo
if (options.memoName) {
const prevMemo = await memoServiceClient.getMemo({ name: options.memoName });
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
if (mask.size === 0) {
return { memoName: prevMemo.name, hasChanges: false };
}
const memo = await memoStore.updateMemo(patch, Array.from(mask));
return { memoName: memo.name, hasChanges: true };
if (mask.size === 0) {
return { memoName: prevMemo.name, hasChanges: false };
}
// 3. Create new memo or comment
const memoData = create(MemoSchema, {
content: state.content,
visibility: state.metadata.visibility,
attachments: allAttachments,
relations: state.metadata.relations,
location: state.metadata.location,
createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined,
updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined,
const memo = await memoServiceClient.updateMemo({
memo: create(MemoSchema, patch as Record<string, unknown>),
updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }),
});
const memo = options.parentMemoName
? await memoServiceClient.createMemoComment({
name: options.parentMemoName,
comment: memoData,
})
: await memoStore.createMemo(memoData);
return { memoName: memo.name, hasChanges: true };
} catch (error) {
if (error instanceof EditorError) {
throw error;
}
throw new EditorError("SAVE_FAILED", error);
}
// 3. Create new memo or comment
const memoData = create(MemoSchema, {
content: state.content,
visibility: state.metadata.visibility,
attachments: allAttachments,
relations: state.metadata.relations,
location: state.metadata.location,
createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined,
updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined,
});
const memo = options.parentMemoName
? await memoServiceClient.createMemoComment({
name: options.parentMemoName,
comment: memoData,
})
: await memoServiceClient.createMemo({ memo: memoData });
return { memoName: memo.name, hasChanges: true };
},
async load(memoName: string): Promise<EditorState> {
try {
const memo = await memoStore.getOrFetchMemoByName(memoName);
if (!memo) {
throw new EditorError("LOAD_FAILED", "Memo not found");
}
const memo = await memoServiceClient.getMemo({ name: memoName });
return {
content: memo.content,
metadata: {
visibility: memo.visibility,
attachments: memo.attachments,
relations: memo.relations,
location: memo.location,
return {
content: memo.content,
metadata: {
visibility: memo.visibility,
attachments: memo.attachments,
relations: memo.relations,
location: memo.location,
},
ui: {
isFocusMode: false,
isLoading: {
saving: false,
uploading: false,
loading: false,
},
ui: {
isFocusMode: false,
isLoading: {
saving: false,
uploading: false,
loading: false,
},
isDragging: false,
isComposing: false,
},
timestamps: {
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
},
localFiles: [],
};
} catch (error) {
if (error instanceof EditorError) {
throw error;
}
throw new EditorError("LOAD_FAILED", error);
}
isDragging: false,
isComposing: false,
},
timestamps: {
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
},
localFiles: [],
};
},
};

View File

@ -1,33 +1,28 @@
import { create } from "@bufbuild/protobuf";
import type { LocalFile } from "@/components/memo-metadata";
import { attachmentStore } from "@/store";
import { attachmentServiceClient } from "@/connect";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { EditorError } from "./errorService";
export const uploadService = {
async uploadFiles(localFiles: LocalFile[]): Promise<Attachment[]> {
if (localFiles.length === 0) return [];
try {
const attachments: Attachment[] = [];
const attachments: Attachment[] = [];
for (const { file } of localFiles) {
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment(
create(AttachmentSchema, {
filename: file.name,
size: BigInt(file.size),
type: file.type,
content: buffer,
}),
);
attachments.push(attachment);
}
return attachments;
} catch (error) {
throw new EditorError("UPLOAD_FAILED", error);
for (const { file } of localFiles) {
const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentServiceClient.createAttachment({
attachment: create(AttachmentSchema, {
filename: file.name,
size: BigInt(file.size),
type: file.type,
content: buffer,
}),
});
attachments.push(attachment);
}
return attachments;
},
};

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
@ -63,7 +62,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
}
};
const MemoExplorer = observer((props: Props) => {
const MemoExplorer = (props: Props) => {
const { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
const currentUser = useCurrentUser();
@ -88,6 +87,6 @@ const MemoExplorer = observer((props: Props) => {
</div>
</aside>
);
});
};
export default MemoExplorer;

View File

@ -1,14 +1,12 @@
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/connect";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateShortcutDialog from "../CreateShortcutDialog";
@ -23,16 +21,17 @@ const getShortcutId = (name: string): string => {
return parts.length === 4 ? parts[3] : "";
};
const ShortcutsSection = observer(() => {
function ShortcutsSection() {
const t = useTranslate();
const shortcuts = userStore.state.shortcuts;
const { shortcuts, refetchSettings } = useAuth();
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => {
await userStore.fetchUserSettings();
}, []);
useEffect(() => {
refetchSettings();
}, [refetchSettings]);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
setDeleteTarget(shortcut);
@ -41,7 +40,7 @@ const ShortcutsSection = observer(() => {
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings();
await refetchSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
};
@ -82,7 +81,7 @@ const ShortcutsSection = observer(() => {
const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = memoFilterStore.shortcut === shortcutId;
const selected = selectedShortcut === shortcutId;
return (
<div
key={shortcutId}
@ -90,7 +89,7 @@ const ShortcutsSection = observer(() => {
>
<span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
onClick={() => (selected ? memoFilterStore.setShortcut(undefined) : memoFilterStore.setShortcut(shortcutId))}
onClick={() => (selected ? setShortcut(undefined) : setShortcut(shortcutId))}
>
{emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()}
@ -131,6 +130,6 @@ const ShortcutsSection = observer(() => {
/>
</div>
);
});
}
export default ShortcutsSection;

View File

@ -1,9 +1,8 @@
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
@ -13,8 +12,9 @@ interface Props {
tagCount: Record<string, number>;
}
const TagsSection = observer((props: Props) => {
const TagsSection = (props: Props) => {
const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
@ -23,13 +23,13 @@ const TagsSection = observer((props: Props) => {
.sort((a, b) => b[1] - a[1]);
const handleTagClick = (tag: string) => {
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,
});
@ -64,7 +64,7 @@ const TagsSection = observer((props: Props) => {
) : (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1.5">
{tags.map(([tag, amount]) => {
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
return (
<div
key={tag}
@ -95,6 +95,6 @@ const TagsSection = observer((props: Props) => {
)}
</div>
);
});
};
export default TagsSection;

View File

@ -11,11 +11,7 @@ import {
SearchIcon,
XIcon,
} from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { memoFilterStore } from "@/store";
import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters } from "@/store/memoFilter";
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useTranslate } from "@/utils/i18n";
interface FilterConfig {
@ -58,38 +54,12 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
},
};
const MemoFilters = observer(() => {
const MemoFilters = () => {
const t = useTranslate();
const [searchParams, setSearchParams] = useSearchParams();
const filters = memoFilterStore.filters;
const lastSyncedUrlRef = useRef("");
const lastSyncedStoreRef = useRef("");
useEffect(() => {
const filterParam = searchParams.get("filter") || "";
if (filterParam !== lastSyncedUrlRef.current) {
lastSyncedUrlRef.current = filterParam;
const newFilters = parseFilterQuery(filterParam);
memoFilterStore.setFilters(newFilters);
lastSyncedStoreRef.current = stringifyFilters(newFilters);
}
}, [searchParams]);
useEffect(() => {
const storeString = stringifyFilters(filters);
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
lastSyncedStoreRef.current = storeString;
const newParams = new URLSearchParams();
if (filters.length > 0) {
newParams.set("filter", storeString);
}
setSearchParams(newParams, { replace: true });
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
}
}, [filters, setSearchParams]);
const { filters, removeFilter } = useMemoFilterContext();
const handleRemoveFilter = (filter: MemoFilter) => {
memoFilterStore.removeFilter((f: MemoFilter) => isEqual(f, filter));
removeFilter((f: MemoFilter) => isEqual(f, filter));
};
const getFilterDisplayText = (filter: MemoFilter): string => {
@ -129,7 +99,7 @@ const MemoFilters = observer(() => {
})}
</div>
);
});
};
MemoFilters.displayName = "MemoFilters";

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { memo } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
@ -12,7 +11,7 @@ interface Props {
reactions: Reaction[];
}
const MemoReactionListView = observer((props: Props) => {
const MemoReactionListView = (props: Props) => {
const { memo: memoData, reactions } = props;
const currentUser = useCurrentUser();
const reactionGroup = useReactionGroups(reactions);
@ -30,6 +29,6 @@ const MemoReactionListView = observer((props: Props) => {
{!readonly && currentUser && <ReactionSelector memo={memoData} />}
</div>
);
});
};
export default memo(MemoReactionListView);

View File

@ -1,9 +1,8 @@
import { SmilePlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useReactionActions } from "./hooks";
@ -13,9 +12,10 @@ interface Props {
onOpenChange?: (open: boolean) => void;
}
const ReactionSelector = observer((props: Props) => {
const ReactionSelector = (props: Props) => {
const { memo, className, onOpenChange } = props;
const [open, setOpen] = useState(false);
const { memoRelatedSetting } = useInstance();
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
@ -26,7 +26,6 @@ const ReactionSelector = observer((props: Props) => {
memo,
onComplete: () => handleOpenChange(false),
});
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@ -42,7 +41,7 @@ const ReactionSelector = observer((props: Props) => {
</PopoverTrigger>
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
{instanceMemoRelatedSetting.reactions.map((reactionType) => (
{memoRelatedSetting.reactions.map((reactionType) => (
<button
type="button"
key={reactionType}
@ -59,6 +58,6 @@ const ReactionSelector = observer((props: Props) => {
</PopoverContent>
</Popover>
);
});
};
export default ReactionSelector;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
@ -13,7 +12,7 @@ interface Props {
users: User[];
}
const ReactionView = observer((props: Props) => {
const ReactionView = (props: Props) => {
const { memo, reactionType, users } = props;
const currentUser = useCurrentUser();
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
@ -54,6 +53,6 @@ const ReactionView = observer((props: Props) => {
</Tooltip>
</TooltipProvider>
);
});
};
export default ReactionView;

View File

@ -1,8 +1,7 @@
import { uniq } from "lodash-es";
import { useEffect, useState } from "react";
import { memoServiceClient } from "@/connect";
import { memoServiceClient, userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoStore, userStore } from "@/store";
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
@ -15,7 +14,8 @@ export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
const fetchReactionGroups = async () => {
const newReactionGroup = new Map<string, User[]>();
for (const reaction of reactions) {
const user = await userStore.getOrFetchUser(reaction.creator);
// Fetch user via gRPC directly since we need it within an effect
const user = await userServiceClient.getUser({ name: reaction.creator });
const users = newReactionGroup.get(reaction.reactionType) || [];
users.push(user);
newReactionGroup.set(reaction.reactionType, uniq(users));
@ -57,7 +57,8 @@ export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptio
reaction: { contentId: memo.name, reactionType },
});
}
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
// Refetch the memo to get updated reactions
await memoServiceClient.getMemo({ name: memo.name });
} catch {
// skip error
}

View File

@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { extractMemoIdFromName } from "@/store/common";
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { LinkType, NodeType } from "./types";
import { convertMemoRelationsToGraphData } from "./utils";

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { memo, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@ -50,7 +49,7 @@ interface Props {
* />
* ```
*/
const MemoView: React.FC<Props> = observer((props: Props) => {
const MemoView: React.FC<Props> = (props: Props) => {
const { memo: memoData, className } = props;
const cardRef = useRef<HTMLDivElement>(null);
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
@ -157,6 +156,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
</article>
</MemoViewContext.Provider>
);
});
};
export default memo(MemoView);

View File

@ -1,19 +1,19 @@
import toast from "react-hot-toast";
import { memoStore, userStore } from "@/store";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
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";
export const useMemoActions = (memo: Memo) => {
const t = useTranslate();
const { mutateAsync: updateMemo } = useUpdateMemo();
const isArchived = memo.state === State.ARCHIVED;
const archiveMemo = async () => {
if (isArchived) return;
try {
await memoStore.updateMemo({ name: memo.name, state: State.ARCHIVED }, ["state"]);
await updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] });
toast.success(t("message.archived-successfully"));
userStore.setStatsStateId();
} catch (error: unknown) {
console.error(error);
const err = error as { details?: string };
@ -23,7 +23,7 @@ export const useMemoActions = (memo: Memo) => {
const unpinMemo = async () => {
if (!memo.pinned) return;
await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
await updateMemo({ update: { name: memo.name, pinned: false }, updateMask: ["pinned"] });
};
return { archiveMemo, unpinMemo };

View File

@ -1,12 +1,6 @@
import { useEffect, useState } from "react";
import { userStore } from "@/store";
import { useUser } from "@/hooks/useUserQueries";
export const useMemoCreator = (creatorName: string) => {
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
useEffect(() => {
userStore.getOrFetchUser(creatorName).then(setCreator);
}, [creatorName]);
const { data: creator } = useUser(creatorName);
return creator;
};

View File

@ -1,5 +1,4 @@
import { useState } from "react";
import { userStore } from "@/store";
export const useMemoEditor = () => {
const [showEditor, setShowEditor] = useState(false);
@ -9,7 +8,6 @@ export const useMemoEditor = () => {
openEditor: () => setShowEditor(true),
handleEditorConfirm: () => {
setShowEditor(false);
userStore.setStatsStateId();
},
handleEditorCancel: () => setShowEditor(false),
};

View File

@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store";
interface UseMemoHandlersOptions {
memoName: string;
@ -13,6 +13,7 @@ interface UseMemoHandlersOptions {
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const { memoName, parentPage, readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo();
const { memoRelatedSetting } = useInstance();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { state: { from: parentPage } });
@ -34,12 +35,12 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const handleMemoContentDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (readonly) return;
if (instanceStore.state.memoRelatedSetting.enableDoubleClickEdit) {
if (memoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault();
openEditor();
}
},
[readonly, openEditor],
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
);
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { instanceStore } from "@/store";
import { useInstance } from "@/contexts/InstanceContext";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface UseNsfwContentReturn {
@ -10,11 +10,11 @@ export interface UseNsfwContentReturn {
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
const { memoRelatedSetting } = useInstance();
const nsfw =
instanceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
memoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => memoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
return {
nsfw: nsfw ?? false,

View File

@ -1,6 +1,5 @@
import { observer } from "mobx-react-lite";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import UserAvatar from "./UserAvatar";
interface Props {
@ -8,9 +7,9 @@ interface Props {
collapsed?: boolean;
}
const MemosLogo = observer((props: Props) => {
function MemosLogo(props: Props) {
const { collapsed } = props;
const instanceGeneralSetting = instanceStore.state.generalSetting;
const { generalSetting: instanceGeneralSetting } = useInstance();
const title = instanceGeneralSetting.customProfile?.title || "Memos";
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
@ -22,6 +21,6 @@ const MemosLogo = observer((props: Props) => {
</div>
</div>
);
});
}
export default MemosLogo;

View File

@ -1,12 +1,10 @@
import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { userStore } from "@/store";
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemosLogo from "./MemosLogo";
@ -24,18 +22,11 @@ interface Props {
className?: string;
}
const Navigation = observer((props: Props) => {
const Navigation = (props: Props) => {
const { collapsed, className } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
useEffect(() => {
if (!currentUser) {
return;
}
userStore.fetchNotifications();
}, []);
const { data: notifications = [] } = useNotifications();
const homeNavLink: NavLinkItem = {
id: "header-memos",
@ -61,7 +52,7 @@ const Navigation = observer((props: Props) => {
title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
};
const unreadCount = userStore.state.notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const inboxNavLink: NavLinkItem = {
id: "header-inbox",
path: Routes.INBOX,
@ -135,6 +126,6 @@ const Navigation = observer((props: Props) => {
)}
</header>
);
});
};
export default Navigation;

View File

@ -1,18 +1,17 @@
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { instanceStore } from "@/store";
import { useInstance } from "@/contexts/InstanceContext";
import Navigation from "./Navigation";
import UserAvatar from "./UserAvatar";
const NavigationDrawer = observer(() => {
const NavigationDrawer = () => {
const location = useLocation();
const [open, setOpen] = useState(false);
const instanceGeneralSetting = instanceStore.state.generalSetting;
const title = instanceGeneralSetting.customProfile?.title || "Memos";
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
const { generalSetting } = useInstance();
const title = generalSetting.customProfile?.title || "Memos";
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
useEffect(() => {
setOpen(false);
@ -34,6 +33,6 @@ const NavigationDrawer = observer(() => {
</SheetContent>
</Sheet>
);
});
};
export default NavigationDrawer;

View File

@ -1,13 +1,14 @@
import { ArrowUpIcon, LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { matchPath } from "react-router-dom";
import PullToRefresh from "react-simple-pull-to-refresh";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { useView } from "@/contexts/ViewContext";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { Routes } from "@/router";
import { memoStore, userStore, viewStore } from "@/store";
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";
@ -28,120 +29,52 @@ interface Props {
showCreator?: boolean;
}
const PagedMemoList = observer((props: Props) => {
const t = useTranslate();
const { md } = useResponsiveWidth();
// Simplified state management - separate state variables for clarity
const [isRequesting, setIsRequesting] = useState(true);
const [nextPageToken, setNextPageToken] = useState("");
// Ref to manage auto-fetch timeout to prevent memory leaks
/**
* Custom hook to auto-fetch more content when page isn't scrollable.
* This ensures users see content without needing to scroll on large screens.
*/
function useAutoFetchWhenNotScrollable({
hasNextPage,
isFetchingNextPage,
memoCount,
onFetchNext,
}: {
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
memoCount: number;
onFetchNext: () => Promise<unknown>;
}) {
const autoFetchTimeoutRef = useRef<number | null>(null);
// Ref to track if initial fetch has been triggered to prevent duplicates
const initialFetchTriggeredRef = useRef(false);
// Apply custom sorting if provided, otherwise use store memos directly
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
// Show memo editor only on the root route
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
// Fetch more memos with pagination support
const fetchMoreMemos = useCallback(
async (pageToken: string) => {
setIsRequesting(true);
try {
const response = await memoStore.fetchMemos({
state: props.state || State.NORMAL,
orderBy: props.orderBy || "display_time desc",
filter: props.filter,
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
pageToken,
});
setNextPageToken(response?.nextPageToken || "");
// Batch-fetch creators in parallel to avoid individual fetches in MemoView
// This significantly improves perceived performance by pre-populating the cache
if (response?.memos && props.showCreator) {
const uniqueCreators = Array.from(new Set(response.memos.map((memo) => memo.creator)));
await Promise.allSettled(uniqueCreators.map((creator) => userStore.getOrFetchUser(creator)));
}
} finally {
setIsRequesting(false);
}
},
[props.state, props.orderBy, props.filter, props.pageSize, props.showCreator],
);
// Helper function to check if page has enough content to be scrollable
const isPageScrollable = useCallback(() => {
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure
return documentHeight > window.innerHeight + 100;
}, []);
// Auto-fetch more content if page isn't scrollable and more data is available
const checkAndFetchIfNeeded = useCallback(async () => {
// Clear any pending auto-fetch timeout
if (autoFetchTimeoutRef.current) {
clearTimeout(autoFetchTimeoutRef.current);
}
// Wait for DOM to update before checking scrollability
await new Promise((resolve) => setTimeout(resolve, 200));
// Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
const shouldFetch = !isPageScrollable() && nextPageToken && !isRequesting && sortedMemoList.length > 0;
const shouldFetch = !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
if (shouldFetch) {
await fetchMoreMemos(nextPageToken);
await onFetchNext();
// Schedule another check with delay to prevent rapid successive calls
autoFetchTimeoutRef.current = window.setTimeout(() => {
checkAndFetchIfNeeded();
void checkAndFetchIfNeeded();
}, 500);
}
}, [nextPageToken, isRequesting, sortedMemoList.length, isPageScrollable, fetchMoreMemos]);
}, [hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
// Refresh the entire memo list from the beginning
const refreshList = useCallback(async () => {
memoStore.state.updateStateId();
setNextPageToken("");
await fetchMoreMemos("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchMoreMemos]);
// Track previous props to detect changes
const propsKey = `${props.state}-${props.orderBy}-${props.filter}-${props.pageSize}`;
const prevPropsKeyRef = useRef<string>();
// Initial load and reload when props change
useEffect(() => {
const propsChanged = prevPropsKeyRef.current !== undefined && prevPropsKeyRef.current !== propsKey;
prevPropsKeyRef.current = propsKey;
// Skip first render if we haven't marked it yet
if (!initialFetchTriggeredRef.current) {
initialFetchTriggeredRef.current = true;
refreshList();
return;
if (!isFetchingNextPage && memoCount > 0) {
void checkAndFetchIfNeeded();
}
// For subsequent changes, refresh if props actually changed
if (propsChanged) {
refreshList();
}
}, [refreshList, propsKey]);
}, [memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
// Auto-fetch more content when list changes and page isn't full
useEffect(() => {
if (!isRequesting && sortedMemoList.length > 0) {
checkAndFetchIfNeeded();
}
}, [sortedMemoList.length, isRequesting, checkAndFetchIfNeeded]);
// Cleanup timeout on component unmount
useEffect(() => {
return () => {
if (autoFetchTimeoutRef.current) {
@ -149,26 +82,74 @@ const PagedMemoList = observer((props: Props) => {
}
};
}, []);
}
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const { layout } = useView();
// Show memo editor only on the root route
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
// Use React Query's infinite query for pagination
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteMemos({
state: props.state || State.NORMAL,
orderBy: props.orderBy || "display_time desc",
filter: props.filter,
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
// Flatten pages into a single array of memos
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
// Apply custom sorting if provided, otherwise use memos directly
const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]);
// Batch-fetch creators when new data arrives to improve performance
useEffect(() => {
if (!data?.pages || !props.showCreator) return;
const lastPage = data.pages[data.pages.length - 1];
if (!lastPage?.memos) return;
const uniqueCreators = Array.from(new Set(lastPage.memos.map((memo) => memo.creator)));
void Promise.allSettled(
uniqueCreators.map((creator) =>
userServiceClient.getUser({ name: creator }).catch(() => {
/* silently ignore errors */
}),
),
);
}, [data?.pages, props.showCreator]);
// Auto-fetch hook: fetches more content when page isn't scrollable
useAutoFetchWhenNotScrollable({
hasNextPage,
isFetchingNextPage,
memoCount: sortedMemoList.length,
onFetchNext: fetchNextPage,
});
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => {
if (!nextPageToken) return;
if (!hasNextPage) return;
const handleScroll = () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
if (nearBottom && !isRequesting) {
fetchMoreMemos(nextPageToken);
if (nearBottom && !isFetchingNextPage) {
fetchNextPage();
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [nextPageToken, isRequesting, fetchMoreMemos]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const children = (
<div className="flex flex-col justify-start items-start w-full max-w-full">
{/* Show skeleton loader during initial load */}
{isRequesting && sortedMemoList.length === 0 ? (
{isLoading ? (
<div className="w-full flex flex-col justify-start items-center">
<MemoSkeleton showCreator={props.showCreator} count={4} />
</div>
@ -185,20 +166,20 @@ const PagedMemoList = observer((props: Props) => {
<MemoFilters />
</>
}
listMode={viewStore.state.layout === "LIST"}
listMode={layout === "LIST"}
/>
{/* Loading indicator for pagination */}
{isRequesting && (
{isFetchingNextPage && (
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state or back-to-top button */}
{!isRequesting && (
{!isFetchingNextPage && (
<>
{!nextPageToken && sortedMemoList.length === 0 ? (
{!hasNextPage && sortedMemoList.length === 0 ? (
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
<Empty />
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
@ -221,7 +202,9 @@ const PagedMemoList = observer((props: Props) => {
return (
<PullToRefresh
onRefresh={() => refreshList()}
onRefresh={async () => {
await refetch();
}}
pullingContent={
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="opacity-60" />
@ -236,7 +219,7 @@ const PagedMemoList = observer((props: Props) => {
{children}
</PullToRefresh>
);
});
};
const BackToTop = () => {
const t = useTranslate();

View File

@ -1,24 +1,25 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { setAccessToken } from "@/auth-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store";
import { initialUserStore } from "@/store/user";
import { useTranslate } from "@/utils/i18n";
const PasswordSignInForm = observer(() => {
function PasswordSignInForm() {
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(instanceStore.state.profile.mode === "demo" ? "demo" : "");
const [password, setPassword] = useState(instanceStore.state.profile.mode === "demo" ? "secret" : "");
const [username, setUsername] = useState(profile.mode === "demo" ? "demo" : "");
const [password, setPassword] = useState(profile.mode === "demo" ? "secret" : "");
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
@ -56,7 +57,7 @@ const PasswordSignInForm = observer(() => {
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialUserStore();
await initialize();
navigateTo("/");
} catch (error: unknown) {
console.error(error);
@ -108,6 +109,6 @@ const PasswordSignInForm = observer(() => {
</div>
</form>
);
});
}
export default PasswordSignInForm;

View File

@ -1,13 +1,13 @@
import { SearchIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils";
import { memoFilterStore } from "@/store";
import { useTranslate } from "@/utils/i18n";
import MemoDisplaySettingMenu from "./MemoDisplaySettingMenu";
const SearchBar = observer(() => {
const SearchBar = () => {
const t = useTranslate();
const { addFilter } = useMemoFilterContext();
const [queryText, setQueryText] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@ -34,7 +34,7 @@ const SearchBar = observer(() => {
if (trimmedText !== "") {
const words = trimmedText.split(/\s+/);
words.forEach((word) => {
memoFilterStore.addFilter({
addFilter({
factor: "contentSearch",
value: word,
});
@ -58,6 +58,6 @@ const SearchBar = observer(() => {
<MemoDisplaySettingMenu className="absolute right-2 top-2 text-sidebar-foreground" />
</div>
);
});
};
export default SearchBar;

View File

@ -30,13 +30,13 @@ const AccessTokenSection = () => {
const [deleteTarget, setDeleteTarget] = useState<PersonalAccessToken | undefined>(undefined);
useEffect(() => {
listAccessTokens(currentUser.name).then((tokens) => {
listAccessTokens(currentUser?.name ?? "").then((tokens) => {
setPersonalAccessTokens(tokens);
});
}, []);
const handleCreateAccessTokenDialogConfirm = async (response: CreatePersonalAccessTokenResponse) => {
const tokens = await listAccessTokens(currentUser.name);
const tokens = await listAccessTokens(currentUser?.name ?? "");
setPersonalAccessTokens(tokens);
// Copy the token to clipboard - this is the only time it will be shown
if (response.token) {

View File

@ -1,6 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
@ -8,9 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import useDialog from "@/hooks/useDialog";
import { instanceStore } from "@/store";
import { buildInstanceSettingName } from "@/store/common";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import {
InstanceSetting_GeneralSetting,
@ -24,27 +22,16 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
// Helper to extract general setting value from InstanceSetting oneof
function getGeneralSetting(setting: any): InstanceSetting_GeneralSetting | undefined {
if (setting?.value?.case === "generalSetting") {
return setting.value.value;
}
return undefined;
}
const InstanceSection = observer(() => {
const InstanceSection = () => {
const t = useTranslate();
const customizeDialog = useDialog();
const originalSetting = create(
InstanceSetting_GeneralSettingSchema,
getGeneralSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)) || {},
);
const { generalSetting: originalSetting, profile, updateSetting, fetchSetting } = useInstance();
const [instanceGeneralSetting, setInstanceGeneralSetting] = useState<InstanceSetting_GeneralSetting>(originalSetting);
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => {
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)]);
}, [originalSetting]);
const handleUpdateCustomizedProfileButtonClick = () => {
customizeDialog.open();
@ -61,15 +48,16 @@ const InstanceSection = observer(() => {
const handleSaveGeneralSetting = async () => {
try {
await instanceStore.upsertInstanceSetting(
await updateSetting(
create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.GENERAL]}`,
value: {
case: "generalSetting",
value: instanceGeneralSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.GENERAL);
} catch (error: any) {
toast.error(error.message);
console.error(error);
@ -122,7 +110,7 @@ const InstanceSection = observer(() => {
<SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator>
<SettingRow label={t("setting.instance-section.disallow-user-registration")}>
<Switch
disabled={instanceStore.state.profile.mode === "demo"}
disabled={profile.mode === "demo"}
checked={instanceGeneralSetting.disallowUserRegistration}
onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })}
/>
@ -130,10 +118,7 @@ const InstanceSection = observer(() => {
<SettingRow label={t("setting.instance-section.disallow-password-auth")}>
<Switch
disabled={
instanceStore.state.profile.mode === "demo" ||
(identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)
}
disabled={profile.mode === "demo" || (identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)}
checked={instanceGeneralSetting.disallowPasswordAuth}
onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })}
/>
@ -188,6 +173,6 @@ const InstanceSection = observer(() => {
/>
</SettingSection>
);
});
};
export default InstanceSection;

View File

@ -2,15 +2,14 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { userStore } from "@/store";
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
import { State } from "@/types/proto/api/v1/common_pb";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -19,10 +18,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
const MemberSection = observer(() => {
const MemberSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [users, setUsers] = useState<User[]>([]);
const { data: users = [], refetch: refetchUsers } = useListUsers();
const deleteUserMutation = useDeleteUser();
const createDialog = useDialog();
const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>();
@ -30,15 +30,6 @@ const MemberSection = observer(() => {
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
const users = await userStore.fetchUsers();
setUsers(users);
};
const stringifyUserRole = (role: User_Role) => {
if (role === User_Role.HOST) {
return "Host";
@ -75,7 +66,7 @@ const MemberSection = observer(() => {
});
setArchiveTarget(undefined);
toast.success(t("setting.member-section.archive-success", { username }));
await fetchUsers();
await refetchUsers();
};
const handleRestoreUserClick = async (user: User) => {
@ -88,7 +79,7 @@ const MemberSection = observer(() => {
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
});
toast.success(t("setting.member-section.restore-success", { username }));
await fetchUsers();
await refetchUsers();
};
const handleDeleteUserClick = async (user: User) => {
@ -98,10 +89,9 @@ const MemberSection = observer(() => {
const confirmDeleteUser = async () => {
if (!deleteTarget) return;
const { username, name } = deleteTarget;
await userStore.deleteUser(name);
deleteUserMutation.mutate(name);
setDeleteTarget(undefined);
toast.success(t("setting.member-section.delete-success", { username }));
await fetchUsers();
};
return (
@ -180,10 +170,10 @@ const MemberSection = observer(() => {
/>
{/* Create User Dialog */}
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={fetchUsers} />
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={refetchUsers} />
{/* Edit User Dialog */}
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={refetchUsers} />
<ConfirmDialog
open={!!archiveTarget}
@ -208,6 +198,6 @@ const MemberSection = observer(() => {
/>
</SettingSection>
);
});
};
export default MemberSection;

View File

@ -1,15 +1,13 @@
import { create } from "@bufbuild/protobuf";
import { isEqual, uniq } from "lodash-es";
import { CheckIcon, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { instanceStore } from "@/store";
import { buildInstanceSettingName } from "@/store/common";
import { useInstance } from "@/contexts/InstanceContext";
import {
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
@ -21,9 +19,9 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
const MemoRelatedSettings = observer(() => {
const MemoRelatedSettings = () => {
const t = useTranslate();
const [originalSetting, setOriginalSetting] = useState<InstanceSetting_MemoRelatedSetting>(instanceStore.state.memoRelatedSetting);
const { memoRelatedSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const [memoRelatedSetting, setMemoRelatedSetting] = useState<InstanceSetting_MemoRelatedSetting>(originalSetting);
const [editingReaction, setEditingReaction] = useState<string>("");
const [editingNsfwTag, setEditingNsfwTag] = useState<string>("");
@ -54,23 +52,23 @@ const MemoRelatedSettings = observer(() => {
setEditingNsfwTag("");
};
const updateSetting = async () => {
const handleUpdateSetting = async () => {
if (memoRelatedSetting.reactions.length === 0) {
toast.error("Reactions must not be empty.");
return;
}
try {
await instanceStore.upsertInstanceSetting(
await updateSetting(
create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED),
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.MEMO_RELATED]}`,
value: {
case: "memoRelatedSetting",
value: memoRelatedSetting,
},
}),
);
setOriginalSetting(memoRelatedSetting);
await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
toast.success(t("message.update-succeed"));
} catch (error: any) {
toast.error(error.message);
@ -179,12 +177,12 @@ const MemoRelatedSettings = observer(() => {
</SettingGroup>
<div className="w-full flex justify-end">
<Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={updateSetting}>
<Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={handleUpdateSetting}>
{t("common.save")}
</Button>
</div>
</SettingSection>
);
});
};
export default MemoRelatedSettings;

View File

@ -29,13 +29,13 @@ const MyAccountSection = () => {
<SettingSection>
<SettingGroup title={t("setting.account-section.title")}>
<div className="w-full flex flex-row justify-start items-center gap-3">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user.avatarUrl} />
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1">
<div className="w-full">
<span className="text-lg font-semibold">{user.displayName}</span>
<span className="ml-2 text-sm text-muted-foreground">@{user.username}</span>
<span className="text-lg font-semibold">{user?.displayName}</span>
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
</div>
{user.description && <p className="w-full text-sm text-muted-foreground truncate">{user.description}</p>}
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleEditAccount}>

View File

@ -1,7 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { observer } from "mobx-react-lite";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { userStore } from "@/store";
import { useAuth } from "@/contexts/AuthContext";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { loadLocale, useTranslate } from "@/utils/i18n";
@ -15,26 +15,48 @@ import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
import WebhookSection from "./WebhookSection";
const PreferencesSection = observer(() => {
const PreferencesSection = () => {
const t = useTranslate();
const generalSetting = userStore.state.userGeneralSetting;
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
// Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale);
// Persist to user settings
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleDefaultMemoVisibilityChanged = async (value: string) => {
await userStore.updateUserGeneralSetting({ memoVisibility: value }, ["memoVisibility"]);
updateUserGeneralSetting(
{ generalSetting: { memoVisibility: value }, updateMask: ["memoVisibility"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleThemeChange = async (theme: string) => {
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
// Provide default values if setting is not loaded yet
@ -85,6 +107,6 @@ const PreferencesSection = observer(() => {
</SettingGroup>
</SettingSection>
);
});
};
export default PreferencesSection;

View File

@ -1,6 +1,5 @@
import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es";
import { observer } from "mobx-react-lite";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
@ -8,8 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { instanceStore } from "@/store";
import { buildInstanceSettingName } from "@/store/common";
import { useInstance } from "@/contexts/InstanceContext";
import {
InstanceSetting_Key,
InstanceSetting_StorageSetting,
@ -24,41 +22,20 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection";
// Helper to extract storage setting value from InstanceSetting oneof
function getStorageSetting(setting: any): InstanceSetting_StorageSetting | undefined {
if (setting?.value?.case === "storageSetting") {
return setting.value.value;
}
return undefined;
}
const StorageSection = observer(() => {
const StorageSection = () => {
const t = useTranslate();
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(
create(
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
),
);
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
useEffect(() => {
setInstanceStorageSetting(
create(
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
),
);
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)]);
setInstanceStorageSetting(originalSetting);
}, [originalSetting]);
const allowSaveStorageSetting = useMemo(() => {
if (instanceStorageSetting.uploadSizeLimitMb <= 0) {
return false;
}
const origin = create(
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
);
if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) {
if (instanceStorageSetting.filepathTemplate.length === 0) {
return false;
@ -74,8 +51,8 @@ const StorageSection = observer(() => {
return false;
}
}
return !isEqual(origin, instanceStorageSetting);
}, [instanceStorageSetting, instanceStore.state]);
return !isEqual(originalSetting, instanceStorageSetting);
}, [instanceStorageSetting, originalSetting]);
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
let num = parseInt(event.target.value);
@ -152,16 +129,22 @@ const StorageSection = observer(() => {
};
const saveInstanceStorageSetting = async () => {
await instanceStore.upsertInstanceSetting(
create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.STORAGE),
value: {
case: "storageSetting",
value: instanceStorageSetting,
},
}),
);
toast.success("Updated");
try {
await updateSetting(
create(InstanceSettingSchema, {
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
value: {
case: "storageSetting",
value: instanceStorageSetting,
},
}),
);
await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated");
} catch (error: any) {
toast.error(error.message);
console.error(error);
}
};
return (
@ -253,6 +236,6 @@ const StorageSection = observer(() => {
</div>
</SettingSection>
);
});
};
export default StorageSection;

View File

@ -1,10 +1,9 @@
import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import i18n from "@/i18n";
import type { MonthNavigatorProps } from "@/types/statistics";
export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
export const MonthNavigator = ({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
const currentMonth = dayjs(visibleMonth).toDate();
const handlePrevMonth = () => {
@ -30,4 +29,4 @@ export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNa
</div>
</div>
);
});
};

View File

@ -1,5 +1,4 @@
import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { useMemo, useState } from "react";
import { CompactMonthCalendar } from "@/components/ActivityCalendar";
import { useDateFilterNavigation } from "@/hooks";
@ -13,7 +12,7 @@ interface Props {
statisticsData: StatisticsData;
}
const StatisticsView = observer((props: Props) => {
const StatisticsView = (props: Props) => {
const { statisticsData } = props;
const { activityStats } = statisticsData;
const navigateToDateFilter = useDateFilterNavigation();
@ -33,6 +32,6 @@ const StatisticsView = observer((props: Props) => {
</div>
</div>
);
});
};
export default StatisticsView;

View File

@ -1,8 +1,7 @@
import { ChevronRightIcon, HashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import useToggle from "react-use/lib/useToggle";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
interface Tag {
key: string;
@ -86,9 +85,10 @@ interface TagItemContainerProps {
expandSubTags: boolean;
}
const TagItemContainer = observer((props: TagItemContainerProps) => {
const TagItemContainer = (props: TagItemContainerProps) => {
const { tag, expandSubTags } = props;
const tagFilters = memoFilterStore.getFiltersByFactor("tagSearch");
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const tagFilters = getFiltersByFactor("tagSearch");
const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
const hasSubTags = tag.subTags.length > 0;
const [showSubTags, toggleSubTags] = useToggle(false);
@ -99,11 +99,11 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
const handleTagClick = () => {
if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
} 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.text,
});
@ -155,6 +155,6 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
) : null}
</>
);
});
};
export default TagTree;

View File

@ -8,9 +8,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useInstance } from "@/contexts/InstanceContext";
import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { instanceStore, userStore } from "@/store";
import { useUpdateUser } from "@/hooks/useUserQueries";
import { User as UserPb, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar";
@ -32,14 +33,15 @@ interface State {
function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const { generalSetting: instanceGeneralSetting } = useInstance();
const { mutateAsync: updateUser } = useUpdateUser();
const [state, setState] = useState<State>({
avatarUrl: currentUser.avatarUrl,
username: currentUser.username,
displayName: currentUser.displayName,
email: currentUser.email,
description: currentUser.description,
avatarUrl: currentUser?.avatarUrl ?? "",
username: currentUser?.username ?? "",
displayName: currentUser?.displayName ?? "",
email: currentUser?.email ?? "",
description: currentUser?.description ?? "",
});
const instanceGeneralSetting = instanceStore.state.generalSetting;
const handleCloseBtnClick = () => {
onOpenChange(false);
@ -112,32 +114,32 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
try {
const updateMask = [];
if (!isEqual(currentUser.username, state.username)) {
if (!isEqual(currentUser?.username, state.username)) {
updateMask.push("username");
}
if (!isEqual(currentUser.displayName, state.displayName)) {
if (!isEqual(currentUser?.displayName, state.displayName)) {
updateMask.push("display_name");
}
if (!isEqual(currentUser.email, state.email)) {
if (!isEqual(currentUser?.email, state.email)) {
updateMask.push("email");
}
if (!isEqual(currentUser.avatarUrl, state.avatarUrl)) {
if (!isEqual(currentUser?.avatarUrl, state.avatarUrl)) {
updateMask.push("avatar_url");
}
if (!isEqual(currentUser.description, state.description)) {
if (!isEqual(currentUser?.description, state.description)) {
updateMask.push("description");
}
await userStore.updateUser(
create(UserSchema, {
name: currentUser.name,
await updateUser({
user: {
name: currentUser?.name,
username: state.username,
displayName: state.displayName,
email: state.email,
avatarUrl: state.avatarUrl,
description: state.description,
}),
},
updateMask,
);
});
toast.success(t("message.update-succeed"));
onSuccess?.();
onOpenChange(false);

View File

@ -6,8 +6,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { instanceStore } from "@/store";
import { buildInstanceSettingName } from "@/store/common";
import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/helpers/resource-names";
import {
InstanceSetting_GeneralSetting_CustomProfile,
InstanceSetting_GeneralSetting_CustomProfileSchema,
@ -24,7 +24,7 @@ interface Props {
function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate();
const instanceGeneralSetting = instanceStore.state.generalSetting;
const { generalSetting: instanceGeneralSetting, updateSetting } = useInstance();
const [customProfile, setCustomProfile] = useState<InstanceSetting_GeneralSetting_CustomProfile>(
create(InstanceSetting_GeneralSetting_CustomProfileSchema, instanceGeneralSetting.customProfile || {}),
);
@ -76,7 +76,7 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
setIsLoading(true);
try {
await instanceStore.upsertInstanceSetting(
await updateSetting(
create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
value: {

View File

@ -1,12 +1,14 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { authServiceClient } from "@/connect";
import { userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import i18n, { locales } from "@/i18n";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { userStore } from "@/store";
import { UserSetting_GeneralSettingSchema, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { getLocaleDisplayName, useTranslate } from "@/utils/i18n";
import { loadTheme, THEME_OPTIONS } from "@/utils/theme";
import UserAvatar from "./UserAvatar";
@ -24,69 +26,107 @@ interface Props {
collapsed?: boolean;
}
const UserMenu = observer((props: Props) => {
const UserMenu = (props: Props) => {
const { collapsed } = props;
const t = useTranslate();
const navigateTo = useNavigateTo();
const currentUser = useCurrentUser();
const generalSetting = userStore.state.userGeneralSetting;
const currentLocale = generalSetting?.locale || "en";
const currentTheme = generalSetting?.theme || "default";
const { userGeneralSetting, refetchSettings, logout } = useAuth();
const currentLocale = userGeneralSetting?.locale || "en";
const currentTheme = userGeneralSetting?.theme || "default";
const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
// Apply locale immediately for instant UI feedback
i18n.changeLanguage(locale);
// Persist to user settings
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
const settingName = `${currentUser.name}/setting`;
const updatedGeneralSetting = create(UserSetting_GeneralSettingSchema, {
locale,
theme: userGeneralSetting?.theme,
memoVisibility: userGeneralSetting?.memoVisibility,
});
await userServiceClient.updateUserSetting({
setting: create(UserSettingSchema, {
name: settingName,
value: {
case: "generalSetting",
value: updatedGeneralSetting,
},
}),
updateMask: create(FieldMaskSchema, { paths: ["general_setting.locale"] }),
});
await refetchSettings();
};
const handleThemeChange = async (theme: string) => {
if (!currentUser) return;
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
const settingName = `${currentUser.name}/setting`;
const updatedGeneralSetting = create(UserSetting_GeneralSettingSchema, {
locale: userGeneralSetting?.locale,
theme,
memoVisibility: userGeneralSetting?.memoVisibility,
});
await userServiceClient.updateUserSetting({
setting: create(UserSettingSchema, {
name: settingName,
value: {
case: "generalSetting",
value: updatedGeneralSetting,
},
}),
updateMask: create(FieldMaskSchema, { paths: ["general_setting.theme"] }),
});
await refetchSettings();
};
const handleSignOut = async () => {
await authServiceClient.signOut({});
// First, clear auth state and cache BEFORE doing anything else
await logout();
// Clear user-specific localStorage items (e.g., drafts)
// Preserve app-wide settings like theme
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
const keysToRemove: string[] = [];
try {
// Then clear user-specific localStorage items
// Preserve app-wide settings like theme
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key)) {
keysToRemove.push(key);
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch {
// Ignore errors from localStorage operations
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Use replace() instead of href to prevent back button from showing cached sensitive data
// This removes the current page from browser history
window.location.replace(Routes.AUTH);
// Always redirect to auth page
window.location.href = Routes.AUTH;
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={!currentUser}>
<div className={cn("w-auto flex flex-row justify-start items-center cursor-pointer text-foreground", collapsed ? "px-1" : "px-3")}>
{currentUser.avatarUrl ? (
<UserAvatar className="shrink-0" avatarUrl={currentUser.avatarUrl} />
{currentUser?.avatarUrl ? (
<UserAvatar className="shrink-0" avatarUrl={currentUser?.avatarUrl} />
) : (
<User2Icon className="w-6 mx-auto h-auto text-muted-foreground" />
)}
{!collapsed && (
<span className="ml-2 text-lg font-medium text-foreground grow truncate">
{currentUser.displayName || currentUser.username}
{currentUser?.displayName || currentUser?.username}
</span>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser.username)}`)}>
<DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
<SquareUserIcon className="size-4 text-muted-foreground" />
{t("common.profile")}
</DropdownMenuItem>
@ -135,6 +175,6 @@ const UserMenu = observer((props: Props) => {
</DropdownMenuContent>
</DropdownMenu>
);
});
};
export default UserMenu;

View File

@ -1,7 +1,7 @@
import { LinkIcon, XIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
import { extractMemoIdFromName } from "@/store/common";
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
import { DisplayMode } from "./types";

View File

@ -1,9 +1,8 @@
import { create } from "@bufbuild/protobuf";
import { LinkIcon, MilestoneIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { memoServiceClient } from "@/connect";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import MetadataCard from "./MetadataCard";
@ -17,7 +16,7 @@ interface RelationListProps extends BaseMetadataProps {
parentPage?: string;
}
const RelationList = observer(({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) => {
function RelationList({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) {
const t = useTranslate();
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
@ -43,7 +42,7 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
(async () => {
if (referencingRelations.length > 0) {
const requests = referencingRelations.map(async (relation) => {
return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
});
const list = await Promise.all(requests);
setReferencingMemos(list);
@ -139,6 +138,6 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
)}
</MetadataCard>
);
});
}
export default RelationList;

View File

@ -2,8 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { getAccessToken, setAccessToken } from "./auth-state";
import { getInstanceConfig } from "./instance-config";
import { ROUTES } from "./router/routes";
import { instanceStore } from "./store";
import { ActivityService } from "./types/proto/api/v1/activity_service_pb";
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
import { AuthService } from "./types/proto/api/v1/auth_service_pb";
@ -37,30 +37,28 @@ const ROUTE_CONFIG = {
// Token Refresh State Management
// ============================================================================
class TokenRefreshManager {
private isRefreshing = false;
private refreshPromise: Promise<void> | null = null;
const createTokenRefreshManager = () => {
let isRefreshing = false;
let refreshPromise: Promise<void> | null = null;
async refresh(refreshFn: () => Promise<void>): Promise<void> {
if (this.isRefreshing && this.refreshPromise) {
return this.refreshPromise;
}
return {
async refresh(refreshFn: () => Promise<void>): Promise<void> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = refreshFn().finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
isRefreshing = true;
refreshPromise = refreshFn().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
return this.refreshPromise;
}
return refreshPromise;
},
};
};
isCurrentlyRefreshing(): boolean {
return this.isRefreshing;
}
}
const tokenRefreshManager = new TokenRefreshManager();
const tokenRefreshManager = createTokenRefreshManager();
// ============================================================================
// Route Access Control
@ -79,7 +77,7 @@ function getAuthFailureRedirect(currentPath: string): string | null {
return null;
}
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
if (getInstanceConfig().memoRelatedSetting.disallowPublicVisibility) {
return ROUTES.AUTH;
}

View File

@ -0,0 +1,148 @@
import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
import { clearAccessToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb";
interface AuthState {
currentUser: User | undefined;
userGeneralSetting: UserSetting_GeneralSetting | undefined;
userWebhooksSetting: UserSetting_WebhooksSetting | undefined;
shortcuts: Shortcut[];
isInitialized: boolean;
isLoading: boolean;
}
interface AuthContextValue extends AuthState {
initialize: () => Promise<void>;
logout: () => Promise<void>;
refetchSettings: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [state, setState] = useState<AuthState>({
currentUser: undefined,
userGeneralSetting: undefined,
userWebhooksSetting: undefined,
shortcuts: [],
isInitialized: false,
isLoading: true,
});
const fetchUserSettings = useCallback(async (userName: string) => {
const [{ settings }, { shortcuts }] = await Promise.all([
userServiceClient.listUserSettings({ parent: userName }),
shortcutServiceClient.listShortcuts({ parent: userName }),
]);
const generalSetting = settings.find((s) => s.value.case === "generalSetting");
const webhooksSetting = settings.find((s) => s.value.case === "webhooksSetting");
return {
userGeneralSetting: generalSetting?.value.case === "generalSetting" ? generalSetting.value.value : undefined,
userWebhooksSetting: webhooksSetting?.value.case === "webhooksSetting" ? webhooksSetting.value.value : undefined,
shortcuts,
};
}, []);
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
const { user: currentUser } = await authServiceClient.getCurrentUser({});
if (!currentUser) {
clearAccessToken();
setState({
currentUser: undefined,
userGeneralSetting: undefined,
userWebhooksSetting: undefined,
shortcuts: [],
isInitialized: true,
isLoading: false,
});
return;
}
const settings = await fetchUserSettings(currentUser.name);
setState({
currentUser,
...settings,
isInitialized: true,
isLoading: false,
});
// Pre-populate React Query cache
queryClient.setQueryData(userKeys.currentUser(), currentUser);
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
} catch (error) {
console.error("Failed to initialize auth:", error);
clearAccessToken();
setState({
currentUser: undefined,
userGeneralSetting: undefined,
userWebhooksSetting: undefined,
shortcuts: [],
isInitialized: true,
isLoading: false,
});
}
}, [fetchUserSettings, queryClient]);
const logout = useCallback(async () => {
try {
await authServiceClient.signOut({});
} catch (error) {
console.error("[AuthContext] Failed to sign out:", error);
} finally {
clearAccessToken();
setState({
currentUser: undefined,
userGeneralSetting: undefined,
userWebhooksSetting: undefined,
shortcuts: [],
isInitialized: true,
isLoading: false,
});
queryClient.clear();
}
}, [queryClient]);
const refetchSettings = useCallback(async () => {
if (!state.currentUser) return;
const settings = await fetchUserSettings(state.currentUser.name);
setState((prev) => ({ ...prev, ...settings }));
}, [state.currentUser, fetchUserSettings]);
return (
<AuthContext.Provider
value={{
...state,
initialize,
logout,
refetchSettings,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
// Convenience hook for just the current user
export function useCurrentUserFromAuth() {
const { currentUser } = useAuth();
return currentUser;
}

View File

@ -0,0 +1,151 @@
import { create } from "@bufbuild/protobuf";
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
import { instanceServiceClient } from "@/connect";
import { updateInstanceConfig } from "@/instance-config";
import {
InstanceProfile,
InstanceProfileSchema,
InstanceSetting,
InstanceSetting_GeneralSetting,
InstanceSetting_GeneralSettingSchema,
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
InstanceSetting_MemoRelatedSettingSchema,
InstanceSetting_StorageSetting,
InstanceSetting_StorageSettingSchema,
} from "@/types/proto/api/v1/instance_service_pb";
const instanceSettingNamePrefix = "instance/settings/";
const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
const keyName = InstanceSetting_Key[key];
return `${instanceSettingNamePrefix}${keyName}`;
};
interface InstanceState {
profile: InstanceProfile;
settings: InstanceSetting[];
isInitialized: boolean;
isLoading: boolean;
}
interface InstanceContextValue extends InstanceState {
generalSetting: InstanceSetting_GeneralSetting;
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
storageSetting: InstanceSetting_StorageSetting;
initialize: () => Promise<void>;
fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
updateSetting: (setting: InstanceSetting) => Promise<void>;
}
const InstanceContext = createContext<InstanceContextValue | null>(null);
export function InstanceProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<InstanceState>({
profile: create(InstanceProfileSchema, {}),
settings: [],
isInitialized: false,
isLoading: true,
});
const getGeneralSetting = (): InstanceSetting_GeneralSetting => {
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}GENERAL`);
if (setting?.value.case === "generalSetting") {
return setting.value.value;
}
return create(InstanceSetting_GeneralSettingSchema, {});
};
const getMemoRelatedSetting = (): InstanceSetting_MemoRelatedSetting => {
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}MEMO_RELATED`);
if (setting?.value.case === "memoRelatedSetting") {
return setting.value.value;
}
return create(InstanceSetting_MemoRelatedSettingSchema, {});
};
const getStorageSetting = (): InstanceSetting_StorageSetting => {
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}STORAGE`);
if (setting?.value.case === "storageSetting") {
return setting.value.value;
}
return create(InstanceSetting_StorageSettingSchema, {});
};
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
const profile = await instanceServiceClient.getInstanceProfile({});
const [generalSetting, memoRelatedSettingResponse] = await Promise.all([
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.GENERAL) }),
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }),
]);
// Update global config for non-React code (like connect.ts interceptors)
if (memoRelatedSettingResponse.value.case === "memoRelatedSetting") {
updateInstanceConfig({
memoRelatedSetting: {
disallowPublicVisibility: memoRelatedSettingResponse.value.value.disallowPublicVisibility,
},
});
}
setState({
profile,
settings: [generalSetting, memoRelatedSettingResponse],
isInitialized: true,
isLoading: false,
});
} catch (error) {
console.error("Failed to initialize instance:", error);
setState((prev) => ({
...prev,
isInitialized: true,
isLoading: false,
}));
}
}, []);
const fetchSetting = useCallback(async (key: InstanceSetting_Key) => {
const setting = await instanceServiceClient.getInstanceSetting({
name: buildInstanceSettingName(key),
});
setState((prev) => ({
...prev,
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
}));
}, []);
const updateSetting = useCallback(async (setting: InstanceSetting) => {
await instanceServiceClient.updateInstanceSetting({ setting });
setState((prev) => ({
...prev,
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
}));
}, []);
return (
<InstanceContext.Provider
value={{
...state,
generalSetting: getGeneralSetting(),
memoRelatedSetting: getMemoRelatedSetting(),
storageSetting: getStorageSetting(),
initialize,
fetchSetting,
updateSetting,
}}
>
{children}
</InstanceContext.Provider>
);
}
export function useInstance() {
const context = useContext(InstanceContext);
if (!context) {
throw new Error("useInstance must be used within InstanceProvider");
}
return context;
}

View File

@ -0,0 +1,156 @@
import { uniqBy } from "lodash-es";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
export type FilterFactor =
| "tagSearch"
| "visibility"
| "contentSearch"
| "displayTime"
| "pinned"
| "property.hasLink"
| "property.hasTaskList"
| "property.hasCode";
export interface MemoFilter {
factor: FilterFactor;
value: string;
}
export const getMemoFilterKey = (filter: MemoFilter): string => `${filter.factor}:${filter.value}`;
export const parseFilterQuery = (query: string | null): MemoFilter[] => {
if (!query) return [];
try {
return query.split(",").map((filterStr) => {
const [factor, value] = filterStr.split(":");
return {
factor: factor as FilterFactor,
value: decodeURIComponent(value || ""),
};
});
} catch {
return [];
}
};
export const stringifyFilters = (filters: MemoFilter[]): string => {
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
};
interface MemoFilterContextValue {
filters: MemoFilter[];
shortcut: string | undefined;
hasActiveFilters: boolean;
getFiltersByFactor: (factor: FilterFactor) => MemoFilter[];
setFilters: (filters: MemoFilter[]) => void;
addFilter: (filter: MemoFilter) => void;
removeFilter: (predicate: (f: MemoFilter) => boolean) => void;
removeFiltersByFactor: (factor: FilterFactor) => void;
clearAllFilters: () => void;
setShortcut: (shortcut?: string) => void;
hasFilter: (filter: MemoFilter) => boolean;
}
const MemoFilterContext = createContext<MemoFilterContextValue | null>(null);
export function MemoFilterProvider({ children }: { children: ReactNode }) {
const [searchParams, setSearchParams] = useSearchParams();
const lastSyncedUrlRef = useRef("");
const lastSyncedStoreRef = useRef("");
// Initialize from URL
const [filters, setFiltersState] = useState<MemoFilter[]>(() => {
return parseFilterQuery(searchParams.get("filter"));
});
const [shortcut, setShortcutState] = useState<string | undefined>(undefined);
// Sync URL to state when URL changes externally
useEffect(() => {
const filterParam = searchParams.get("filter") || "";
if (filterParam !== lastSyncedUrlRef.current) {
lastSyncedUrlRef.current = filterParam;
const newFilters = parseFilterQuery(filterParam);
setFiltersState(newFilters);
lastSyncedStoreRef.current = stringifyFilters(newFilters);
}
}, [searchParams]);
// Sync state to URL when state changes
useEffect(() => {
const storeString = stringifyFilters(filters);
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
lastSyncedStoreRef.current = storeString;
const newParams = new URLSearchParams(searchParams);
if (filters.length > 0) {
newParams.set("filter", storeString);
} else {
newParams.delete("filter");
}
setSearchParams(newParams, { replace: true });
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
}
}, [filters, searchParams, setSearchParams]);
const getFiltersByFactor = useCallback((factor: FilterFactor) => filters.filter((f) => f.factor === factor), [filters]);
const setFilters = useCallback((newFilters: MemoFilter[]) => {
setFiltersState(newFilters);
}, []);
const addFilter = useCallback((filter: MemoFilter) => {
setFiltersState((prev) => uniqBy([...prev, filter], getMemoFilterKey));
}, []);
const removeFilter = useCallback((predicate: (f: MemoFilter) => boolean) => {
setFiltersState((prev) => prev.filter((f) => !predicate(f)));
}, []);
const removeFiltersByFactor = useCallback((factor: FilterFactor) => {
setFiltersState((prev) => prev.filter((f) => f.factor !== factor));
}, []);
const clearAllFilters = useCallback(() => {
setFiltersState([]);
setShortcutState(undefined);
}, []);
const setShortcut = useCallback((newShortcut?: string) => {
setShortcutState(newShortcut);
}, []);
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>
);
}
export function useMemoFilterContext() {
const context = useContext(MemoFilterContext);
if (!context) {
throw new Error("useMemoFilterContext must be used within MemoFilterProvider");
}
return context;
}
// Alias for backwards compatibility during migration
export const useMemoFilter = useMemoFilterContext;

View File

@ -0,0 +1,79 @@
import { createContext, type ReactNode, useContext, useState } from "react";
export type LayoutMode = "LIST" | "MASONRY";
interface ViewContextValue {
orderByTimeAsc: boolean;
layout: LayoutMode;
toggleSortOrder: () => void;
setLayout: (layout: LayoutMode) => void;
}
const ViewContext = createContext<ViewContextValue | null>(null);
const LOCAL_STORAGE_KEY = "memos-view-setting";
export function ViewProvider({ children }: { children: ReactNode }) {
// Load initial state from localStorage
const getInitialState = () => {
try {
const cached = localStorage.getItem(LOCAL_STORAGE_KEY);
if (cached) {
const data = JSON.parse(cached);
return {
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? false),
layout: (["LIST", "MASONRY"].includes(data.layout) ? data.layout : "LIST") as LayoutMode,
};
}
} catch (error) {
console.warn("Failed to load view settings from localStorage:", error);
}
return { orderByTimeAsc: false, layout: "LIST" as LayoutMode };
};
const [viewState, setViewState] = useState(getInitialState);
const persistToStorage = (newState: typeof viewState) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState));
} catch (error) {
console.warn("Failed to persist view settings:", error);
}
};
const toggleSortOrder = () => {
setViewState((prev) => {
const newState = { ...prev, orderByTimeAsc: !prev.orderByTimeAsc };
persistToStorage(newState);
return newState;
});
};
const setLayout = (layout: LayoutMode) => {
setViewState((prev) => {
const newState = { ...prev, layout };
persistToStorage(newState);
return newState;
});
};
return (
<ViewContext.Provider
value={{
...viewState,
toggleSortOrder,
setLayout,
}}
>
{children}
</ViewContext.Provider>
);
}
export function useView() {
const context = useContext(ViewContext);
if (!context) {
throw new Error("useView must be used within ViewProvider");
}
return context;
}

View File

@ -0,0 +1,56 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { attachmentServiceClient } from "@/connect";
// Query keys factory
export const attachmentKeys = {
all: ["attachments"] as const,
lists: () => [...attachmentKeys.all, "list"] as const,
list: (filters?: any) => [...attachmentKeys.lists(), filters] as const,
details: () => [...attachmentKeys.all, "detail"] as const,
detail: (name: string) => [...attachmentKeys.details(), name] as const,
};
// Hook to fetch attachments
export function useAttachments() {
return useQuery({
queryKey: attachmentKeys.lists(),
queryFn: async () => {
const { attachments } = await attachmentServiceClient.listAttachments({});
return attachments;
},
});
}
// Hook to create/upload attachment
export function useCreateAttachment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (attachment: any) => {
const result = await attachmentServiceClient.createAttachment({ attachment });
return result;
},
onSuccess: () => {
// Invalidate attachments list
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
},
});
}
// Hook to delete attachment
export function useDeleteAttachment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
await attachmentServiceClient.deleteAttachment({ name });
return name;
},
onSuccess: (name) => {
// Remove from cache
queryClient.removeQueries({ queryKey: attachmentKeys.detail(name) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
},
});
}

View File

@ -1,7 +1,8 @@
import { userStore } from "@/store";
import { useAuth } from "@/contexts/AuthContext";
const useCurrentUser = () => {
return userStore.state.userMapByName[userStore.state.currentUser || ""];
const { currentUser } = useAuth();
return currentUser;
};
export default useCurrentUser;

View File

@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { stringifyFilters } from "@/store/memoFilter";
import { stringifyFilters } from "@/contexts/MemoFilterContext";
export const useDateFilterNavigation = () => {
const navigate = useNavigate();

View File

@ -1,8 +1,9 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs";
import { countBy } from "lodash-es";
import { useEffect, useState } from "react";
import { memoStore, userStore } from "@/store";
import { useMemo } from "react";
import { useMemos } from "@/hooks/useMemoQueries";
import { useUserStats } from "@/hooks/useUserQueries";
import type { StatisticsData } from "@/types/statistics";
export interface FilteredMemoStats {
@ -11,100 +12,68 @@ export interface FilteredMemoStats {
loading: boolean;
}
const getUserStatsKey = (userName: string): string => {
return `${userName}/stats`;
};
export interface UseFilteredMemoStatsOptions {
userName?: string;
}
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options;
const [data, setData] = useState<FilteredMemoStats>({
statistics: {
activityStats: {},
},
tags: {},
loading: false,
});
// React to memo store changes (create, update, delete)
const memoStoreStateId = memoStore.state.stateId;
// React to user stats changes (for tag counts)
const userStatsStateId = userStore.state.statsStateId;
useEffect(() => {
const computeStats = async () => {
let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {};
let useBackendStats = false;
// Fetch user stats if userName is provided
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
// Try to use backend user stats if userName is provided
if (userName) {
// Check if stats are already cached, otherwise fetch them
const statsKey = getUserStatsKey(userName);
let userStats = userStore.state.userStatsByName[statsKey];
// Fetch memos for fallback computation (or when userName is not provided)
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({});
if (!userStats) {
try {
await userStore.fetchUserStats(userName);
userStats = userStore.state.userStatsByName[statsKey];
} catch (error) {
console.error("Failed to fetch user stats:", error);
// Will fall back to computing from cache below
}
}
const data = useMemo(() => {
const loading = isLoadingUserStats || isLoadingMemos;
let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {};
if (userStats) {
// Use activity timestamps from user stats
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
activityStats = countBy(
userStats.memoDisplayTimestamps
.map((ts) => (ts ? timestampDate(ts) : undefined))
.filter((date): date is Date => date !== undefined)
.map((date) => dayjs(date).format("YYYY-MM-DD")),
);
}
// Use tag counts from user stats
if (userStats.tagCount) {
tagCount = userStats.tagCount;
}
useBackendStats = true;
}
// Try to use backend user stats if userName is provided and available
if (userName && userStats) {
// Use activity timestamps from user stats
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
activityStats = countBy(
userStats.memoDisplayTimestamps
.map((ts) => (ts ? timestampDate(ts) : undefined))
.filter((date): date is Date => date !== undefined)
.map((date) => dayjs(date).format("YYYY-MM-DD")),
);
}
// Fallback: compute from cached memos if backend stats not available
// Use tag counts from user stats
if (userStats.tagCount) {
tagCount = userStats.tagCount;
}
} else if (memosResponse?.memos) {
// Fallback: compute from memos if backend stats not available
// Also used for Explore and Archived contexts
if (!useBackendStats) {
const displayTimeList: Date[] = [];
const memos = memoStore.state.memos;
const displayTimeList: Date[] = [];
const memos = memosResponse.memos;
for (const memo of memos) {
// Collect display timestamps for activity calendar
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
if (displayTime) {
displayTimeList.push(displayTime);
}
// Count tags
if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
for (const memo of memos) {
// Collect display timestamps for activity calendar
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
if (displayTime) {
displayTimeList.push(displayTime);
}
// Count tags
if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
}
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
}
setData({
statistics: { activityStats },
tags: tagCount,
loading: false,
});
};
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
}
computeStats();
}, [memoStoreStateId, userStatsStateId, userName]);
return {
statistics: { activityStats },
tags: tagCount,
loading,
};
}, [userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
return data;
};

View File

@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { instanceServiceClient } from "@/connect";
import { InstanceSetting, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
// Query keys factory
export const instanceKeys = {
all: ["instance"] as const,
profile: () => [...instanceKeys.all, "profile"] as const,
settings: () => [...instanceKeys.all, "settings"] as const,
setting: (key: InstanceSetting_Key) => [...instanceKeys.settings(), key] as const,
};
// Build setting name from key
const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
const keyName = InstanceSetting_Key[key];
return `instance/settings/${keyName}`;
};
// Hook to fetch instance profile
export function useInstanceProfile() {
return useQuery({
queryKey: instanceKeys.profile(),
queryFn: async () => {
const profile = await instanceServiceClient.getInstanceProfile({});
return profile;
},
staleTime: 1000 * 60 * 10, // 10 minutes - instance profile rarely changes
});
}
// Hook to fetch a specific instance setting
export function useInstanceSetting(key: InstanceSetting_Key) {
return useQuery({
queryKey: instanceKeys.setting(key),
queryFn: async () => {
const setting = await instanceServiceClient.getInstanceSetting({
name: buildInstanceSettingName(key),
});
return setting;
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// Hook to update instance setting
export function useUpdateInstanceSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (setting: InstanceSetting) => {
await instanceServiceClient.updateInstanceSetting({ setting });
return setting;
},
onSuccess: (setting) => {
// Extract key from setting name and invalidate
const keyMatch = setting.name.match(/instance\/settings\/(\w+)/);
if (keyMatch) {
const keyName = keyMatch[1] as keyof typeof InstanceSetting_Key;
const key = InstanceSetting_Key[keyName];
if (key !== undefined) {
queryClient.setQueryData(instanceKeys.setting(key), setting);
}
}
queryClient.invalidateQueries({ queryKey: instanceKeys.settings() });
},
});
}
// Derived hooks for common settings
export function useGeneralSetting() {
const { data: setting, ...rest } = useInstanceSetting(InstanceSetting_Key.GENERAL);
const generalSetting = setting?.value.case === "generalSetting" ? setting.value.value : undefined;
return { data: generalSetting, ...rest };
}
export function useMemoRelatedSetting() {
const { data: setting, ...rest } = useInstanceSetting(InstanceSetting_Key.MEMO_RELATED);
const memoRelatedSetting = setting?.value.case === "memoRelatedSetting" ? setting.value.value : undefined;
return { data: memoRelatedSetting, ...rest };
}

View File

@ -1,10 +1,27 @@
import { useMemo } from "react";
import { instanceStore, userStore } from "@/store";
import { extractUserIdFromName, getVisibilityName } from "@/store/common";
import memoFilterStore from "@/store/memoFilter";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
const extractUserIdFromName = (name: string): string => {
const match = name.match(/users\/(\d+)/);
return match ? match[1] : "";
};
const getVisibilityName = (visibility: Visibility): string => {
switch (visibility) {
case Visibility.PUBLIC:
return "PUBLIC";
case Visibility.PROTECTED:
return "PROTECTED";
case Visibility.PRIVATE:
return "PRIVATE";
default:
return "PRIVATE";
}
};
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : "";
@ -20,10 +37,9 @@ export interface UseMemoFiltersOptions {
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {
const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options;
// Extract MobX observable values to avoid issues with React dependency tracking
const currentShortcut = memoFilterStore.shortcut;
const shortcuts = userStore.state.shortcuts;
const filters = memoFilterStore.filters;
const { shortcuts } = useAuth();
const { filters, shortcut: currentShortcut } = useMemoFilterContext();
const { memoRelatedSetting } = useInstance();
// Get selected shortcut if needed
const selectedShortcut = useMemo(() => {
@ -31,7 +47,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
return shortcuts.find((shortcut) => getShortcutId(shortcut.name) === currentShortcut);
}, [includeShortcuts, currentShortcut, shortcuts]);
// Build filter - wrapped in useMemo but also using observer for reactivity
// Build filter
return useMemo(() => {
const conditions: string[] = [];
@ -45,7 +61,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
conditions.push(selectedShortcut.filter);
}
// Add active filters from memoFilterStore
// Add active filters from context
for (const filter of filters) {
if (filter.factor === "contentSearch") {
conditions.push(`content.contains("${filter.value}")`);
@ -55,7 +71,6 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
if (includePinned) {
conditions.push(`pinned`);
}
// Skip pinned filter if not enabled
} else if (filter.factor === "property.hasLink") {
conditions.push(`has_link`);
} else if (filter.factor === "property.hasTaskList") {
@ -63,12 +78,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
} else if (filter.factor === "property.hasCode") {
conditions.push(`has_code`);
} else if (filter.factor === "displayTime") {
// Check instance setting for display time factor
const setting = instanceStore.getInstanceSettingByKey(InstanceSetting_Key.MEMO_RELATED);
const displayWithUpdateTime = setting?.value.case === "memoRelatedSetting" ? setting.value.value.displayWithUpdateTime : false;
const displayWithUpdateTime = memoRelatedSetting?.displayWithUpdateTime ?? false;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
// Convert date to UTC timestamp range
const filterDate = new Date(filter.value);
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
const timestampAfter = filterUtcTimestamp / 1000;
@ -77,15 +89,12 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
}
}
// Add visibility filter if specified (for Explore page)
// Add visibility filter if specified
if (visibilities && visibilities.length > 0) {
// Build visibility filter based on allowed visibility levels
// Format: visibility in ["PUBLIC", "PROTECTED"]
// Convert enum values to string names (e.g., 3 -> "PUBLIC", 2 -> "PROTECTED")
const visibilityValues = visibilities.map((v) => `"${getVisibilityName(v)}"`).join(", ");
conditions.push(`visibility in [${visibilityValues}]`);
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;
}, [creatorName, includeShortcuts, includePinned, visibilities, selectedShortcut, filters]);
}, [creatorName, includeShortcuts, includePinned, visibilities, selectedShortcut, filters, memoRelatedSetting]);
};

View File

@ -0,0 +1,168 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { memoServiceClient } from "@/connect";
import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb";
import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
// Query keys factory for consistent cache management
export const memoKeys = {
all: ["memos"] as const,
lists: () => [...memoKeys.all, "list"] as const,
list: (filters: Partial<ListMemosRequest>) => [...memoKeys.lists(), filters] as const,
details: () => [...memoKeys.all, "detail"] as const,
detail: (name: string) => [...memoKeys.details(), name] as const,
};
/**
* Hook to fetch a list of memos with filtering and sorting.
* @param request - Request parameters (state, orderBy, filter, pageSize)
*/
export function useMemos(request: Partial<ListMemosRequest> = {}) {
return useQuery({
queryKey: memoKeys.list(request),
queryFn: async () => {
const response = await memoServiceClient.listMemos(create(ListMemosRequestSchema, request as Record<string, unknown>));
return response;
},
});
}
/**
* Hook for infinite scrolling/pagination of memos.
* Automatically fetches pages as the user scrolls.
*
* @param request - Partial request configuration (state, orderBy, filter, pageSize)
* @returns React Query infinite query result with pages of memos
*/
export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}) {
return useInfiniteQuery({
queryKey: memoKeys.list(request),
queryFn: async ({ pageParam }) => {
const response = await memoServiceClient.listMemos(
create(ListMemosRequestSchema, {
...request,
pageToken: pageParam || "",
} as Record<string, unknown>),
);
return response;
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
staleTime: 1000 * 60, // Consider data fresh for 1 minute
gcTime: 1000 * 60 * 5, // Keep unused data in cache for 5 minutes
});
}
/**
* Hook to fetch a single memo by its resource name.
* @param name - Memo resource name (e.g., "memos/123")
* @param options - Query options including enabled flag
*/
export function useMemo(name: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: memoKeys.detail(name),
queryFn: async () => {
const memo = await memoServiceClient.getMemo({ name });
return memo;
},
enabled: options?.enabled ?? true,
staleTime: 1000 * 60, // 1 minute - memos can be edited frequently
});
}
/**
* Hook to create a new memo.
* Automatically invalidates memo lists and user stats on success.
*/
export function useCreateMemo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (memoToCreate: Memo) => {
const memo = await memoServiceClient.createMemo({ memo: memoToCreate });
return memo;
},
onSuccess: (newMemo) => {
// Invalidate memo lists to refetch
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// Add new memo to cache
queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo);
// Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
},
});
}
/**
* Hook to update an existing memo with optimistic updates.
* Implements rollback on error for better UX.
*/
export function useUpdateMemo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ update, updateMask }: { update: Partial<Memo>; updateMask: string[] }) => {
const memo = await memoServiceClient.updateMemo({
memo: create(MemoSchema, update as Record<string, unknown>),
updateMask: create(FieldMaskSchema, { paths: updateMask }),
});
return memo;
},
onMutate: async ({ update }) => {
if (!update.name) {
return { previousMemo: undefined };
}
// Cancel outgoing refetches to prevent race conditions
await queryClient.cancelQueries({ queryKey: memoKeys.detail(update.name) });
// Snapshot previous value for rollback on error
const previousMemo = queryClient.getQueryData<Memo>(memoKeys.detail(update.name));
// Optimistically update the cache
if (previousMemo) {
queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...update });
}
return { previousMemo };
},
onError: (_err, { update }, context) => {
// Rollback on error
if (context?.previousMemo && update.name) {
queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo);
}
},
onSuccess: (updatedMemo) => {
// Update cache with server response
queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
},
});
}
/**
* Hook to delete a memo.
* Automatically removes memo from cache and invalidates lists on success.
*/
export function useDeleteMemo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
await memoServiceClient.deleteMemo({ name });
return name;
},
onSuccess: (name) => {
// Remove from cache
queryClient.removeQueries({ queryKey: memoKeys.detail(name) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
// Invalidate user stats
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
},
});
}

View File

@ -1,7 +1,7 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs";
import { useMemo } from "react";
import { viewStore } from "@/store";
import { useView } from "@/contexts/ViewContext";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
@ -17,9 +17,7 @@ export interface UseMemoSortingResult {
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
const { pinnedFirst = false, state = State.NORMAL } = options;
// Extract MobX observable values to avoid issues with React dependency tracking
const orderByTimeAsc = viewStore.state.orderByTimeAsc;
const { orderByTimeAsc } = useView();
// Generate orderBy string for API
const orderBy = useMemo(() => {

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { userStore } from "@/store";
import { useAuth } from "@/contexts/AuthContext";
import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
/**
@ -9,7 +9,7 @@ import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
*/
export const useUserLocale = () => {
const { i18n } = useTranslation();
const userGeneralSetting = userStore.state.userGeneralSetting;
const { userGeneralSetting } = useAuth();
// Apply locale when user setting changes or user logs in
useEffect(() => {

View File

@ -0,0 +1,257 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect";
import { buildUserSettingName } from "@/helpers/resource-names";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
// Query keys factory
export const userKeys = {
all: ["users"] as const,
details: () => [...userKeys.all, "detail"] as const,
detail: (name: string) => [...userKeys.details(), name] as const,
stats: () => [...userKeys.all, "stats"] as const,
userStats: (name: string) => [...userKeys.stats(), name] as const,
currentUser: () => [...userKeys.all, "current"] as const,
shortcuts: () => [...userKeys.all, "shortcuts"] as const,
notifications: () => [...userKeys.all, "notifications"] as const,
};
/**
* Hook to get the current authenticated user.
* Data is cached for 5 minutes as auth state changes infrequently.
*/
export function useCurrentUser() {
return useQuery({
queryKey: userKeys.currentUser(),
queryFn: async () => {
const { user } = await authServiceClient.getCurrentUser({});
return user;
},
staleTime: 1000 * 60 * 5, // 5 minutes - auth doesn't change often
});
}
/**
* Hook to fetch a specific user by name.
* @param name - User resource name (e.g., "users/123")
* @param options - Query options including enabled flag
*/
export function useUser(name: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: userKeys.detail(name),
queryFn: async () => {
const user = await userServiceClient.getUser({ name });
return user;
},
enabled: options?.enabled ?? true,
staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often
});
}
/**
* Hook to fetch statistics for a specific user.
* @param username - User resource name (e.g., "users/123")
*/
export function useUserStats(username?: string) {
return useQuery({
queryKey: username ? userKeys.userStats(username) : userKeys.stats(),
queryFn: async () => {
if (!username) {
throw new Error("Username is required");
}
const stats = await userServiceClient.getUserStats({ name: username });
return stats;
},
enabled: !!username,
});
}
/**
* Hook to fetch shortcuts for the current user.
*/
export function useShortcuts() {
return useQuery({
queryKey: userKeys.shortcuts(),
queryFn: async () => {
const { shortcuts } = await shortcutServiceClient.listShortcuts({});
return shortcuts;
},
});
}
/**
* Hook to fetch notifications for the current user.
* Only fetches when a user is authenticated.
*/
export function useNotifications() {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: userKeys.notifications(),
queryFn: async () => {
if (!currentUser?.name) {
return [];
}
const { notifications } = await userServiceClient.listUserNotifications({ parent: currentUser.name });
return notifications;
},
enabled: !!currentUser?.name,
staleTime: 1000 * 30, // 30 seconds - notifications should update frequently
});
}
/**
* Hook to fetch tag counts for autocomplete suggestions.
* @param forCurrentUser - If true, fetches only current user's tags; if false, fetches all public tags
*/
export function useTagCounts(forCurrentUser = false) {
const { data: currentUser } = useCurrentUser();
return useQuery({
queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"],
queryFn: async () => {
if (forCurrentUser) {
// Fetch current user stats only
if (!currentUser?.name) {
return {};
}
const stats = await userServiceClient.getUserStats({ name: currentUser.name });
return stats.tagCount || {};
} else {
// Fetch all user stats
const { stats } = await userServiceClient.listAllUserStats({});
// Aggregate tag counts from all users
const tagCount: Record<string, number> = {};
for (const userStats of stats) {
if (userStats.tagCount) {
for (const [tag, count] of Object.entries(userStats.tagCount)) {
tagCount[tag] = (tagCount[tag] || 0) + count;
}
}
}
return tagCount;
}
},
enabled: !forCurrentUser || !!currentUser?.name,
staleTime: 1000 * 60 * 2, // 2 minutes - tags don't change frequently
});
}
/**
* Hook to update a user's profile.
* Automatically updates the cache on success.
*/
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ user, updateMask }: { user: Partial<User>; updateMask: string[] }) => {
const updatedUser = await userServiceClient.updateUser({
user: user as User,
updateMask: create(FieldMaskSchema, { paths: updateMask }),
});
return updatedUser;
},
onSuccess: (updatedUser) => {
queryClient.setQueryData(userKeys.detail(updatedUser.name), updatedUser);
queryClient.invalidateQueries({ queryKey: userKeys.currentUser() });
},
});
}
/**
* Hook to delete a user.
* Automatically removes the user from cache on success.
*/
export function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
await userServiceClient.deleteUser({ name });
return name;
},
onSuccess: (name) => {
queryClient.removeQueries({ queryKey: userKeys.detail(name) });
queryClient.invalidateQueries({ queryKey: userKeys.all });
},
});
}
// Hook to fetch user settings
export function useUserSettings(parent?: string) {
return useQuery({
queryKey: [...userKeys.all, "settings", parent],
queryFn: async () => {
if (!parent) return { settings: [], shortcuts: [] };
const [{ settings }, { shortcuts }] = await Promise.all([
userServiceClient.listUserSettings({ parent }),
shortcutServiceClient.listShortcuts({ parent }),
]);
return { settings, shortcuts };
},
enabled: !!parent,
});
}
// Hook to update user setting
export function useUpdateUserSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ setting, updateMask }: { setting: UserSetting; updateMask: string[] }) => {
const updatedSetting = await userServiceClient.updateUserSetting({
setting,
updateMask: create(FieldMaskSchema, { paths: updateMask }),
});
return updatedSetting;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...userKeys.all, "settings"] });
},
});
}
// Hook to list all users
export function useListUsers() {
return useQuery({
queryKey: userKeys.all,
queryFn: async () => {
const { users } = await userServiceClient.listUsers({});
return users;
},
});
}
// Hook to update user general setting (convenience wrapper)
export function useUpdateUserGeneralSetting(currentUserName?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ generalSetting, updateMask }: { generalSetting: Partial<UserSetting_GeneralSetting>; updateMask: string[] }) => {
if (!currentUserName) {
throw new Error("No current user");
}
const settingName = buildUserSettingName(currentUserName, UserSetting_Key.GENERAL);
const userSetting = create(UserSettingSchema, {
name: settingName,
value: {
case: "generalSetting",
value: generalSetting as UserSetting_GeneralSetting,
},
});
const updatedSetting = await userServiceClient.updateUserSetting({
setting: userSetting,
updateMask: create(FieldMaskSchema, { paths: updateMask }),
});
return updatedSetting;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...userKeys.all, "settings"] });
},
});
}

View File

@ -1,5 +1,5 @@
import { useEffect } from "react";
import { userStore } from "@/store";
import { useAuth } from "@/contexts/AuthContext";
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme";
/**
@ -7,7 +7,7 @@ import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/uti
* Priority: User setting localStorage system preference
*/
export const useUserTheme = () => {
const userGeneralSetting = userStore.state.userGeneralSetting;
const { userGeneralSetting } = useAuth();
// Apply theme when user setting changes or user logs in
useEffect(() => {

View File

@ -0,0 +1,23 @@
// Simple configuration module for instance settings
// This allows non-React code (like connect.ts interceptors) to access instance settings
// The values are updated by InstanceContext when it initializes
interface InstanceConfig {
memoRelatedSetting: {
disallowPublicVisibility: boolean;
};
}
let instanceConfig: InstanceConfig = {
memoRelatedSetting: {
disallowPublicVisibility: false,
},
};
export function getInstanceConfig(): InstanceConfig {
return instanceConfig;
}
export function updateInstanceConfig(config: Partial<InstanceConfig>): void {
instanceConfig = { ...instanceConfig, ...config };
}

View File

@ -1,17 +1,16 @@
import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useState } from "react";
import { matchPath, Outlet, useLocation } from "react-router-dom";
import type { MemoExplorerContext } from "@/components/MemoExplorer";
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
import MobileHeader from "@/components/MobileHeader";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { userStore } from "@/store";
const MainLayout = observer(() => {
const MainLayout = () => {
const { md, lg } = useResponsiveWidth();
const location = useLocation();
const currentUser = useCurrentUser();
@ -34,8 +33,8 @@ const MainLayout = observer(() => {
if (username) {
// Fetch or get user to obtain user name (e.g., "users/123")
// Note: User stats will be fetched by useFilteredMemoStats
userStore
.getOrFetchUser(`users/${username}`)
userServiceClient
.getUser({ name: `users/${username}` })
.then((user) => {
setProfileUserName(user.name);
})
@ -86,6 +85,6 @@ const MainLayout = observer(() => {
</div>
</section>
);
});
};
export default MainLayout;

View File

@ -1,30 +1,30 @@
import { observer } from "mobx-react-lite";
import { Suspense, useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious";
import Navigation from "@/components/Navigation";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import Loading from "@/pages/Loading";
import { Routes } from "@/router";
import { instanceStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
const RootLayout = observer(() => {
const RootLayout = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const { sm } = useResponsiveWidth();
const currentUser = useCurrentUser();
const { memoRelatedSetting } = useInstance();
const { removeFilter } = useMemoFilterContext();
const [initialized, setInitialized] = useState(false);
const pathname = useMemo(() => location.pathname, [location.pathname]);
const prevPathname = usePrevious(pathname);
useEffect(() => {
if (!currentUser) {
// If disallowPublicVisibility is enabled, redirect to the login page if the user is not logged in.
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
// Use replace() to prevent back button from showing cached sensitive data
// If disallowPublicVisibility is enabled, redirect to login
if (memoRelatedSetting.disallowPublicVisibility) {
window.location.replace(Routes.AUTH);
return;
} else if (
@ -35,14 +35,14 @@ const RootLayout = observer(() => {
}
}
setInitialized(true);
}, []);
}, [currentUser, memoRelatedSetting.disallowPublicVisibility, location.pathname]);
useEffect(() => {
// When the route changes and there is no filter in the search params, remove all filters.
// When the route changes and there is no filter in the search params, remove all filters
if (prevPathname !== pathname && !searchParams.has("filter")) {
memoFilterStore.removeFilter(() => true);
removeFilter(() => true);
}
}, [prevPathname, pathname, searchParams]);
}, [prevPathname, pathname, searchParams, removeFilter]);
return !initialized ? (
<Loading />
@ -66,6 +66,6 @@ const RootLayout = observer(() => {
</main>
</div>
);
});
};
export default RootLayout;

View File

@ -0,0 +1,17 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Memos app is real-time focused, so we want fresh data
staleTime: 1000 * 10, // 10 seconds
gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 1,
},
},
});

View File

@ -1,37 +1,69 @@
import "@github/relative-time-element";
import { observer } from "mobx-react-lite";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
import { RouterProvider } from "react-router-dom";
import "./i18n";
import "./index.css";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
import { ViewProvider } from "@/contexts/ViewContext";
import { queryClient } from "@/lib/query-client";
import Loading from "@/pages/Loading";
import router from "./router";
// Configure MobX before importing any stores
import "./store/config";
import { initialInstanceStore } from "./store/instance";
import { initialUserStore } from "./store/user";
import { applyLocaleEarly } from "./utils/i18n";
import { applyThemeEarly } from "./utils/theme";
import "leaflet/dist/leaflet.css";
// Apply theme and locale early to prevent flash of wrong theme/language
// This uses localStorage as the source before user settings are loaded
// Apply theme and locale early to prevent flash
applyThemeEarly();
applyLocaleEarly();
const Main = observer(() => (
<>
<RouterProvider router={router} />
<Toaster position="top-right" />
</>
));
// Inner component that initializes contexts
function AppInitializer({ children }: { children: React.ReactNode }) {
const { isInitialized: authInitialized, initialize: initAuth } = useAuth();
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
const [initStarted, setInitStarted] = useState(false);
(async () => {
// Initialize stores
await initialInstanceStore();
await initialUserStore();
// Initialize on mount
useEffect(() => {
if (initStarted) return;
setInitStarted(true);
const container = document.getElementById("root");
const root = createRoot(container as HTMLElement);
root.render(<Main />);
})();
const init = async () => {
await initInstance();
await initAuth();
};
init();
}, [initAuth, initInstance, initStarted]);
if (!authInitialized || !instanceInitialized) {
return <Loading />;
}
return <>{children}</>;
}
function Main() {
return (
<QueryClientProvider client={queryClient}>
<InstanceProvider>
<AuthProvider>
<ViewProvider>
<AppInitializer>
<RouterProvider router={router} />
<Toaster position="top-right" />
</AppInitializer>
</ViewProvider>
</AuthProvider>
</InstanceProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
const container = document.getElementById("root");
const root = createRoot(container as HTMLElement);
root.render(<Main />);

View File

@ -1,10 +1,9 @@
import { observer } from "mobx-react-lite";
import AuthFooter from "@/components/AuthFooter";
import PasswordSignInForm from "@/components/PasswordSignInForm";
import { instanceStore } from "@/store";
import { useInstance } from "@/contexts/InstanceContext";
const AdminSignIn = observer(() => {
const instanceGeneralSetting = instanceStore.state.generalSetting;
const AdminSignIn = () => {
const { generalSetting: instanceGeneralSetting } = useInstance();
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
@ -19,6 +18,6 @@ const AdminSignIn = observer(() => {
<AuthFooter />
</div>
);
});
};
export default AdminSignIn;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
@ -7,12 +6,12 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Archived = observer(() => {
const Archived = () => {
const user = useCurrentUser();
// Build filter using unified hook (no shortcuts or pinned filter)
const memoFilter = useMemoFilters({
creatorName: user.name,
creatorName: user?.name,
includeShortcuts: false,
includePinned: false,
});
@ -34,6 +33,6 @@ const Archived = observer(() => {
filter={memoFilter}
/>
);
});
};
export default Archived;

View File

@ -1,7 +1,6 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs";
import { ExternalLinkIcon, PaperclipIcon, SearchIcon, Trash } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
@ -13,11 +12,11 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { attachmentServiceClient } from "@/connect";
import { useDeleteAttachment } from "@/hooks/useAttachmentQueries";
import useDialog from "@/hooks/useDialog";
import useLoading from "@/hooks/useLoading";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import i18n from "@/i18n";
import { attachmentStore } from "@/store";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -68,11 +67,12 @@ const AttachmentItem = ({ attachment }: AttachmentItemProps) => (
</div>
);
const Attachments = observer(() => {
const Attachments = () => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const loadingState = useLoading();
const deleteUnusedAttachmentsDialog = useDialog();
const { mutateAsync: deleteAttachment } = useDeleteAttachment();
const [searchQuery, setSearchQuery] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
@ -149,7 +149,7 @@ const Attachments = observer(() => {
// Delete all unused attachments
const handleDeleteUnusedAttachments = useCallback(async () => {
try {
await Promise.all(unusedAttachments.map((attachment) => attachmentStore.deleteAttachment(attachment.name)));
await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name)));
toast.success(t("resource.delete-all-unused-success"));
} catch (error) {
console.error("Failed to delete unused attachments:", error);
@ -157,7 +157,7 @@ const Attachments = observer(() => {
} finally {
await handleRefetch();
}
}, [unusedAttachments, t, handleRefetch]);
}, [unusedAttachments, t, handleRefetch, deleteAttachment]);
// Handle search input change
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@ -266,6 +266,6 @@ const Attachments = observer(() => {
/>
</section>
);
});
};
export default Attachments;

View File

@ -1,13 +1,12 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { initialUserStore } from "@/store/user";
import { validateOAuthState } from "@/utils/oauth";
interface State {
@ -15,8 +14,9 @@ interface State {
errorMessage: string;
}
const AuthCallback = observer(() => {
const AuthCallback = () => {
const navigateTo = useNavigateTo();
const { initialize } = useAuth();
const [searchParams] = useSearchParams();
const [state, setState] = useState<State>({
loading: true,
@ -91,7 +91,7 @@ const AuthCallback = observer(() => {
loading: false,
errorMessage: "",
});
await initialUserStore();
await initialize();
// Redirect to return URL if specified, otherwise home
navigateTo(returnUrl || "/");
} catch (error: unknown) {
@ -114,6 +114,6 @@ const AuthCallback = observer(() => {
)}
</div>
);
});
};
export default AuthCallback;

View File

@ -1,5 +1,4 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useMemo, useState } from "react";
import {
CompactMonthCalendar,
@ -20,7 +19,7 @@ import { useTranslate } from "@/utils/i18n";
const MIN_YEAR = 2000;
const MAX_YEAR = new Date().getFullYear() + 1;
const Calendar = observer(() => {
const Calendar = () => {
const currentUser = useCurrentUser();
const t = useTranslate();
const navigateToDateFilter = useDateFilterNavigation();
@ -140,6 +139,6 @@ const Calendar = observer(() => {
</div>
</section>
);
});
};
export default Calendar;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
@ -7,7 +6,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
const Explore = observer(() => {
const Explore = () => {
const currentUser = useCurrentUser();
// Determine visibility filter based on authentication status
@ -40,6 +39,6 @@ const Explore = observer(() => {
showCreator
/>
);
});
};
export default Explore;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
@ -7,12 +6,12 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Home = observer(() => {
const Home = () => {
const user = useCurrentUser();
// Build filter using unified hook
const memoFilter = useMemoFilters({
creatorName: user.name,
creatorName: user?.name,
includeShortcuts: true,
includePinned: true,
});
@ -35,6 +34,6 @@ const Home = observer(() => {
/>
</div>
);
});
};
export default Home;

View File

@ -1,23 +1,25 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useState } from "react";
import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
import MobileHeader from "@/components/MobileHeader";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import { UserNotification, UserNotification_Status, UserNotification_Type } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const Inboxes = observer(() => {
const Inboxes = () => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all");
const allNotifications = sortBy(userStore.state.notifications, (notification: UserNotification) => {
// Fetch notifications with React Query
const { data: fetchedNotifications = [] } = useNotifications();
const allNotifications = sortBy(fetchedNotifications, (notification: UserNotification) => {
return -((notification.createTime ? timestampDate(notification.createTime) : undefined)?.getTime() || 0);
});
@ -30,18 +32,6 @@ const Inboxes = observer(() => {
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length;
const fetchNotifications = async () => {
try {
await userStore.fetchNotifications();
} catch (error) {
console.error("Failed to fetch notifications:", error);
}
};
useEffect(() => {
fetchNotifications();
}, []);
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && <MobileHeader />}
@ -127,6 +117,6 @@ const Inboxes = observer(() => {
</div>
</section>
);
});
};
export default Inboxes;

View File

@ -1,7 +1,6 @@
import { ConnectError } from "@connectrpc/connect";
import { ArrowUpLeftFromCircleIcon, MessageCircleIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link, useLocation, useParams } from "react-router-dom";
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
@ -9,16 +8,16 @@ import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import { Button } from "@/components/ui/button";
import { memoNamePrefix } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemo as useMemoQuery } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { memoNamePrefix } from "@/store/common";
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const MemoDetail = observer(() => {
const MemoDetail = () => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const params = useParams();
@ -27,45 +26,34 @@ const MemoDetail = observer(() => {
const currentUser = useCurrentUser();
const uid = params.uid;
const memoName = `${memoNamePrefix}${uid}`;
const memo = memoStore.getMemoByName(memoName);
const [parentMemo, setParentMemo] = useState<Memo | undefined>(undefined);
const [showCommentEditor, setShowCommentEditor] = useState(false);
// Fetch main memo with React Query
const { data: memo, error, isLoading } = useMemoQuery(memoName, { enabled: !!memoName });
// Handle errors
if (error) {
toast.error((error as ConnectError).message);
navigateTo("/403");
}
// Fetch parent memo if exists
const { data: parentMemo } = useMemoQuery(memo?.parent || "", {
enabled: !!memo?.parent,
});
// Get comment relations and memo names
const commentRelations =
memo?.relations.filter((relation) => relation.relatedMemo?.name === memo.name && relation.type === MemoRelation_Type.COMMENT) || [];
const comments = commentRelations.map((relation) => memoStore.getMemoByName(relation.memo!.name)).filter((memo) => memo) as any as Memo[];
const commentMemoNames = commentRelations.map((relation) => relation.memo!.name);
// Fetch all comment memos
const commentQueries = commentMemoNames.map((name) => useMemoQuery(name));
const comments = commentQueries.map((q) => q.data).filter((memo): memo is Memo => !!memo);
const showCreateCommentButton = currentUser && !showCommentEditor;
// Prepare memo.
useEffect(() => {
if (memoName) {
memoStore.getOrFetchMemoByName(memoName).catch((error: ConnectError) => {
toast.error(error.message);
navigateTo("/403");
});
} else {
navigateTo("/404");
}
}, [memoName]);
// Prepare memo comments.
useEffect(() => {
if (!memo) {
return;
}
(async () => {
if (memo.parent) {
memoStore.getOrFetchMemoByName(memo.parent).then((memo: Memo) => {
setParentMemo(memo);
});
} else {
setParentMemo(undefined);
}
await Promise.all(commentRelations.map((relation) => memoStore.getOrFetchMemoByName(relation.memo!.name)));
})();
}, [memo]);
if (!memo) {
if (isLoading || !memo) {
return null;
}
@ -74,8 +62,7 @@ const MemoDetail = observer(() => {
};
const handleCommentCreated = async (memoCommentName: string) => {
await memoStore.getOrFetchMemoByName(memoCommentName);
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
// React Query will auto-refetch due to invalidation in the mutation
setShowCommentEditor(false);
};
@ -174,6 +161,6 @@ const MemoDetail = observer(() => {
</div>
</section>
);
});
};
export default MemoDetail;

View File

@ -1,5 +1,4 @@
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import MobileHeader from "@/components/MobileHeader";
@ -12,9 +11,9 @@ import SectionMenuItem from "@/components/Settings/SectionMenuItem";
import SSOSection from "@/components/Settings/SSOSection";
import StorageSection from "@/components/Settings/StorageSection";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { instanceStore } from "@/store";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -37,15 +36,16 @@ const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
sso: KeyIcon,
};
const Setting = observer(() => {
const Setting = () => {
const t = useTranslate();
const { md } = useResponsiveWidth();
const location = useLocation();
const user = useCurrentUser();
const { profile, fetchSetting } = useInstance();
const [state, setState] = useState<State>({
selectedSection: "my-account",
});
const isHost = user.role === User_Role.HOST;
const isHost = user?.role === User_Role.HOST;
const settingsSectionList = useMemo(() => {
let settingList = [...BASIC_SECTIONS];
@ -74,10 +74,10 @@ const Setting = observer(() => {
// Initial fetch for instance settings.
(async () => {
[InstanceSetting_Key.MEMO_RELATED, InstanceSetting_Key.STORAGE].forEach(async (key) => {
await instanceStore.fetchInstanceSetting(key);
await fetchSetting(key);
});
})();
}, [isHost]);
}, [isHost, fetchSetting]);
const handleSectionSelectorItemClick = useCallback((settingSection: SettingSection) => {
window.location.hash = settingSection;
@ -115,7 +115,7 @@ const Setting = observer(() => {
/>
))}
<span className="px-3 mt-2 opacity-70 text-sm">
{t("setting.version")}: v{instanceStore.state.profile.version}
{t("setting.version")}: v{profile.version}
</span>
</div>
</>
@ -156,6 +156,6 @@ const Setting = observer(() => {
</div>
</section>
);
});
};
export default Setting;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
@ -7,27 +6,27 @@ import PasswordSignInForm from "@/components/PasswordSignInForm";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import { extractIdentityProviderIdFromName } from "@/helpers/resource-names";
import { absolutifyLink } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser";
import { Routes } from "@/router";
import { instanceStore } from "@/store";
import { extractIdentityProviderIdFromName } from "@/store/common";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n";
import { storeOAuthState } from "@/utils/oauth";
const SignIn = observer(() => {
const SignIn = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const instanceGeneralSetting = instanceStore.state.generalSetting;
const { generalSetting: instanceGeneralSetting } = useInstance();
// Redirect to root page if already signed in.
useEffect(() => {
if (currentUser) {
if (currentUser?.name) {
window.location.href = Routes.ROOT;
}
}, []);
}, [currentUser]);
// Prepare identity provider list.
useEffect(() => {
@ -79,7 +78,7 @@ const SignIn = observer(() => {
{!instanceGeneralSetting.disallowPasswordAuth ? (
<PasswordSignInForm />
) : (
identityProviderList.length == 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
)}
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
<p className="w-full mt-4 text-sm">
@ -117,6 +116,6 @@ const SignIn = observer(() => {
<AuthFooter />
</div>
);
});
};
export default SignIn;

View File

@ -1,7 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
@ -10,20 +9,21 @@ import AuthFooter from "@/components/AuthFooter";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient, userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store";
import { initialUserStore } from "@/store/user";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const SignUp = observer(() => {
const SignUp = () => {
const t = useTranslate();
const navigateTo = useNavigateTo();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const instanceGeneralSetting = instanceStore.state.generalSetting;
const { generalSetting: instanceGeneralSetting, profile } = useInstance();
const { initialize } = useAuth();
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
@ -67,7 +67,7 @@ const SignUp = observer(() => {
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialUserStore();
await initialize();
navigateTo("/");
} catch (error: unknown) {
console.error(error);
@ -131,7 +131,7 @@ const SignUp = observer(() => {
) : (
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
)}
{!instanceStore.state.profile.owner ? (
{!profile.owner ? (
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
) : (
<p className="w-full mt-4 text-sm">
@ -145,6 +145,6 @@ const SignUp = observer(() => {
<AuthFooter />
</div>
);
});
};
export default SignUp;

View File

@ -1,7 +1,5 @@
import copy from "copy-to-clipboard";
import { ExternalLinkIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useParams } from "react-router-dom";
import { MemoRenderContext } from "@/components/MasonryView";
@ -10,36 +8,29 @@ import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useLoading from "@/hooks/useLoading";
import { userStore } from "@/store";
import { useUser } from "@/hooks/useUserQueries";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const UserProfile = observer(() => {
const UserProfile = () => {
const t = useTranslate();
const params = useParams();
const loadingState = useLoading();
const [user, setUser] = useState<User>();
const username = params.username;
useEffect(() => {
const username = params.username;
if (!username) {
throw new Error("username is required");
}
// Fetch user with React Query
const {
data: user,
isLoading,
error,
} = useUser(`users/${username}`, {
enabled: !!username,
});
userStore
.getOrFetchUser(`users/${username}`)
.then((user) => {
setUser(user);
loadingState.setFinish();
})
.catch((error) => {
console.error(error);
toast.error(t("message.user-not-found"));
});
}, [params.username]);
// Handle errors
if (error && !isLoading) {
toast.error(t("message.user-not-found"));
}
// Build filter using unified hook (no shortcuts, but includes pinned)
const memoFilter = useMemoFilters({
@ -65,7 +56,7 @@ const UserProfile = observer(() => {
return (
<section className="w-full min-h-full flex flex-col justify-start items-center">
{!loadingState.isLoading &&
{!isLoading &&
(user ? (
<>
{/* User profile header - centered with max width */}
@ -107,6 +98,6 @@ const UserProfile = observer(() => {
))}
</section>
);
});
};
export default UserProfile;

View File

@ -1,277 +0,0 @@
# Store Architecture
This directory contains the application's state management implementation using MobX.
## Overview
The store architecture follows a clear separation of concerns:
- **Server State Stores**: Manage data fetched from the backend API
- **Client State Stores**: Manage UI preferences and transient state
## Store Files
### Server State Stores (API Data)
| Store | File | Purpose |
|-------|------|---------|
| `memoStore` | `memo.ts` | Memo CRUD operations, optimistic updates |
| `userStore` | `user.ts` | User authentication, settings, stats |
| `instanceStore` | `instance.ts` | Instance profile and settings |
| `attachmentStore` | `attachment.ts` | File attachment management |
**Features:**
- ✅ Request deduplication (prevents duplicate API calls)
- ✅ Structured error handling with `StoreError`
- ✅ Computed property memoization for performance
- ✅ Optimistic updates (immediate UI feedback)
- ✅ Automatic caching
### Client State Stores (UI State)
| Store | File | Purpose | Persistence |
|-------|------|---------|-------------|
| `viewStore` | `view.ts` | Display preferences (sort, layout) | localStorage |
| `memoFilterStore` | `memoFilter.ts` | Active search filters | URL params |
**Features:**
- ✅ No API calls (instant updates)
- ✅ localStorage persistence (viewStore)
- ✅ URL synchronization (memoFilterStore - shareable links)
### Utilities
| File | Purpose |
|------|---------|
| `base-store.ts` | Base classes and factory functions |
| `store-utils.ts` | Request deduplication, error handling, optimistic updates |
| `config.ts` | MobX configuration |
| `common.ts` | Shared constants and utilities |
| `index.ts` | Centralized exports |
## Usage Examples
### Basic Store Usage
```typescript
import { memoStore, userStore, viewStore } from "@/store";
import { observer } from "mobx-react-lite";
const MyComponent = observer(() => {
// Access state
const memos = memoStore.state.memos;
const currentUser = userStore.state.currentUser;
const sortOrder = viewStore.state.orderByTimeAsc;
// Call actions
const handleCreate = async () => {
await memoStore.createMemo({ content: "Hello" });
};
const toggleSort = () => {
viewStore.toggleSortOrder();
};
return <div>...</div>;
});
```
### Server Store Pattern
```typescript
// Fetch data with automatic deduplication
const memo = await memoStore.getOrFetchMemoByName("memos/123");
// Update with optimistic UI updates
await memoStore.updateMemo({ name: "memos/123", content: "Updated" }, ["content"]);
// Errors are wrapped in StoreError
try {
await memoStore.deleteMemo("memos/123");
} catch (error) {
if (error instanceof StoreError) {
console.error(error.code, error.message);
}
}
```
### Client Store Pattern
```typescript
// View preferences (persisted to localStorage)
viewStore.setLayout("MASONRY");
viewStore.toggleSortOrder();
// Filters (synced to URL)
memoFilterStore.addFilter({ factor: "tagSearch", value: "work" });
memoFilterStore.removeFiltersByFactor("tagSearch");
memoFilterStore.clearAllFilters();
```
## Creating New Stores
### Server State Store
```typescript
import { StandardState, createServerStore } from "./base-store";
import { createRequestKey, StoreError } from "./store-utils";
class MyState extends StandardState {
dataMap: Record<string, Data> = {};
get items() {
return Object.values(this.dataMap);
}
}
const myStore = (() => {
const base = createServerStore(new MyState(), {
name: "myStore",
enableDeduplication: true,
});
const { state, executeRequest } = base;
const fetchItems = async () => {
return executeRequest(
createRequestKey("fetchItems"),
async () => {
const items = await api.fetchItems();
state.setPartial({ dataMap: items });
return items;
},
"FETCH_ITEMS_FAILED"
);
};
return { state, fetchItems };
})();
```
### Client State Store
```typescript
import { StandardState } from "./base-store";
class MyState extends StandardState {
preference: string = "default";
setPartial(partial: Partial<MyState>) {
Object.assign(this, partial);
// Optional: persist to localStorage
localStorage.setItem("my-preference", JSON.stringify(this));
}
}
const myStore = (() => {
const state = new MyState();
const setPreference = (value: string) => {
state.setPartial({ preference: value });
};
return { state, setPreference };
})();
```
## Best Practices
### ✅ Do
- Use `observer()` HOC for components that access store state
- Call store actions from event handlers
- Use computed properties for derived state
- Handle errors from async store operations
- Keep stores focused on a single domain
### ❌ Don't
- Don't mutate store state directly - use `setPartial()` or action methods
- Don't call async store methods during render
- Don't mix server and client state in the same store
- Don't access stores outside of React components (except initialization)
## Performance Tips
1. **Computed Properties**: Use getters for derived state - they're memoized by MobX
2. **Request Deduplication**: Automatic for server stores - prevents wasted API calls
3. **Optimistic Updates**: Used in `updateMemo` - immediate UI feedback
4. **Fine-grained Reactivity**: MobX only re-renders components that access changed properties
## Testing
```typescript
import { memoStore } from "@/store";
describe("memoStore", () => {
it("should fetch memos", async () => {
const memos = await memoStore.fetchMemos({ filter: "..." });
expect(memos).toBeDefined();
});
it("should cache memos", () => {
const memo = memoStore.getMemoByName("memos/123");
expect(memo).toBeDefined();
});
});
```
## Migration Guide
If you're migrating from old store patterns:
1. **Replace direct state mutations** with `setPartial()`:
```typescript
// Before
store.state.value = 5;
// After
store.state.setPartial({ value: 5 });
```
2. **Wrap API calls** with `executeRequest()`:
```typescript
// Before
const data = await api.fetch();
state.data = data;
// After
return executeRequest("fetchData", async () => {
const data = await api.fetch();
state.setPartial({ data });
return data;
}, "FETCH_FAILED");
```
3. **Use StandardState** for new stores:
```typescript
// Before
class State {
constructor() { makeAutoObservable(this); }
}
// After
class State extends StandardState {
// makeAutoObservable() called automatically
}
```
## Troubleshooting
**Q: Component not re-rendering when state changes?**
A: Make sure you wrapped it with `observer()` from `mobx-react-lite`.
**Q: Getting "Cannot modify state outside of actions" error?**
A: Use `state.setPartial()` instead of direct mutations.
**Q: API calls firing multiple times?**
A: Check that your store uses `createServerStore()` with deduplication enabled.
**Q: localStorage not persisting?**
A: Ensure your client store overrides `setPartial()` to call `localStorage.setItem()`.
## Resources
- [MobX Documentation](https://mobx.js.org/)
- [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite)
- [Store Pattern Guide](./base-store.ts)

Some files were not shown because too many files have changed in this diff Show More