Unverified Commit f87f728b authored by Johnny's avatar Johnny Committed by GitHub

feat: react query migration (#5379)

parent 4109fe32
......@@ -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
......
......@@ -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",
......
......@@ -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:
......
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;
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>
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);
<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 (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
/>
);
}),
)}
</div>
</TooltipProvider>
);
}),
);
</div>
</TooltipProvider>
);
});
ActivityCalendar.displayName = "ActivityCalendar";
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();
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const { weeks } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate: "",
});
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 (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
size={size}
/>
);
}),
)}
</div>
);
}),
);
export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick } = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const weekStartDayOffset = generalSetting.weekStartDayOffset;
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const { weeks } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate: "",
});
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 (
<CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day}
maxCount={maxCount}
tooltipText={tooltipText}
onClick={onClick}
size={size}
/>
);
}),
)}
</div>
);
});
CompactMonthCalendar.displayName = "CompactMonthCalendar";
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;
......@@ -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);
......
......@@ -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,
});
......
......@@ -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);
......
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;
......@@ -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;
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,
......
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>
);
});
};
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 }}
/>
);
});
};
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,
});
......
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)
......
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);
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;
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;
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;
}
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({
......
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;
......@@ -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}")`);
}
......
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));
// 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
// 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));
}
......
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) {
......
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,
},
ui: {
isFocusMode: false,
isLoading: {
saving: false,
uploading: false,
loading: false,
},
isDragging: false,
isComposing: false,
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,
},
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: [],
};
},
};
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;
},
};
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;
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;
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;
......@@ -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";
......
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);
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;
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;
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
}
......
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";
......
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);
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 };
......
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;
};
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),
};
......
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 };
......
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,
......
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;
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;
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;
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;
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;
......@@ -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) {
......
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;
......@@ -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;
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;
......@@ -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}>
......
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;
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;
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>
);
});
};
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;
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;
......@@ -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);
......
......@@ -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: {
......
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));
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch {
// Ignore errors from localStorage operations
}
// 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;
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";
......
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;
......@@ -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;
}
this.isRefreshing = true;
this.refreshPromise = refreshFn().finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return {
async refresh(refreshFn: () => Promise<void>): Promise<void> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
return this.refreshPromise;
}
isRefreshing = true;
refreshPromise = refreshFn().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
isCurrentlyRefreshing(): boolean {
return this.isRefreshing;
}
}
return refreshPromise;
},
};
};
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;
}
......
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;
}
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;
}
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;
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;
}
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() });
},
});
}
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;
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();
......
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;
};
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 };
}
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]);
};
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"] });
},
});
}
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(() => {
......
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(() => {
......
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"] });
},
});
}
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(() => {
......
// 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 };
}
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;
This diff is collapsed.
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,
},
},
});
This diff is collapsed.
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;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment