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 ...@@ -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/` - Each driver has its own migration files in `store/db/{driver}/migration/`
- Schema version tracked in `instance_setting` table (key: `bb.general.version`) - Schema version tracked in `instance_setting` table (key: `bb.general.version`)
**Why MobX for frontend state?** **Why React Query + Context for frontend state?**
- Simpler than Redux for this application's needs - **Server state** (memos, users, attachments) managed by React Query (TanStack Query v5)
- Stores in `web/src/store/` handle global state (user, memos, editor, dialogs) - 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 ## Critical Development Commands
...@@ -32,7 +39,7 @@ golangci-lint run # Lint ...@@ -32,7 +39,7 @@ golangci-lint run # Lint
**Frontend:** **Frontend:**
```bash ```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 lint:fix # Lint and fix
cd web && pnpm release # Build and copy to backend cd web && pnpm release # Build and copy to backend
``` ```
...@@ -42,6 +49,42 @@ 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 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 ## Key Workflows
**Modifying APIs:** **Modifying APIs:**
...@@ -66,7 +109,7 @@ cd proto && buf generate # Regenerate Go + TypeScript from . ...@@ -66,7 +109,7 @@ cd proto && buf generate # Regenerate Go + TypeScript from .
**Entry point:** `cmd/memos/` starts the server **Entry point:** `cmd/memos/` starts the server
**API layer:** `server/router/api/v1/` implements gRPC services **API layer:** `server/router/api/v1/` implements gRPC services
**Data layer:** `store/` handles all persistence **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 ## Testing Expectations
......
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
...@@ -45,8 +47,6 @@ ...@@ -45,8 +47,6 @@
"mermaid": "^11.12.1", "mermaid": "^11.12.1",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"mime": "^4.1.0", "mime": "^4.1.0",
"mobx": "^6.15.0",
"mobx-react-lite": "^4.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-force-graph-2d": "^1.29.0", "react-force-graph-2d": "^1.29.0",
......
...@@ -68,6 +68,12 @@ importers: ...@@ -68,6 +68,12 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.17 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)) 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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
...@@ -113,12 +119,6 @@ importers: ...@@ -113,12 +119,6 @@ importers:
mime: mime:
specifier: ^4.1.0 specifier: ^4.1.0
version: 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: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
...@@ -1342,6 +1342,23 @@ packages: ...@@ -1342,6 +1342,23 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 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': '@tweenjs/tween.js@25.0.0':
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
...@@ -2403,22 +2420,6 @@ packages: ...@@ -2403,22 +2420,6 @@ packages:
mlly@1.8.0: mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} 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: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
...@@ -2883,11 +2884,6 @@ packages: ...@@ -2883,11 +2884,6 @@ packages:
'@types/react': '@types/react':
optional: true 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: uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true hasBin: true
...@@ -3990,6 +3986,21 @@ snapshots: ...@@ -3990,6 +3986,21 @@ snapshots:
tailwindcss: 4.1.17 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) 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': {} '@tweenjs/tween.js@25.0.0': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
...@@ -5376,16 +5387,6 @@ snapshots: ...@@ -5376,16 +5387,6 @@ snapshots:
pkg-types: 1.3.1 pkg-types: 1.3.1
ufo: 1.6.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: {} ms@2.1.3: {}
nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
...@@ -5921,10 +5922,6 @@ snapshots: ...@@ -5921,10 +5922,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 18.3.27 '@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: {} uuid@11.1.0: {}
vfile-location@5.0.3: vfile-location@5.0.3:
......
import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useInstance } from "./contexts/InstanceContext";
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
import useNavigateTo from "./hooks/useNavigateTo"; import useNavigateTo from "./hooks/useNavigateTo";
import { useUserLocale } from "./hooks/useUserLocale"; import { useUserLocale } from "./hooks/useUserLocale";
import { useUserTheme } from "./hooks/useUserTheme"; import { useUserTheme } from "./hooks/useUserTheme";
import { instanceStore } from "./store";
import { cleanupExpiredOAuthState } from "./utils/oauth"; import { cleanupExpiredOAuthState } from "./utils/oauth";
const App = observer(() => { const App = () => {
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const instanceProfile = instanceStore.state.profile; const { profile: instanceProfile, generalSetting: instanceGeneralSetting } = useInstance();
const instanceGeneralSetting = instanceStore.state.generalSetting;
// Apply user preferences reactively // Apply user preferences reactively
useUserLocale(); useUserLocale();
...@@ -21,12 +20,12 @@ const App = observer(() => { ...@@ -21,12 +20,12 @@ const App = observer(() => {
cleanupExpiredOAuthState(); cleanupExpiredOAuthState();
}, []); }, []);
// Redirect to sign up page if no instance owner. // Redirect to sign up page if no instance owner
useEffect(() => { useEffect(() => {
if (!instanceProfile.owner) { if (!instanceProfile.owner) {
navigateTo("/auth/signup"); navigateTo("/auth/signup");
} }
}, [instanceProfile.owner]); }, [instanceProfile.owner, navigateTo]);
useEffect(() => { useEffect(() => {
if (instanceGeneralSetting.additionalStyle) { if (instanceGeneralSetting.additionalStyle) {
...@@ -45,7 +44,7 @@ const App = observer(() => { ...@@ -45,7 +44,7 @@ const App = observer(() => {
} }
}, [instanceGeneralSetting.additionalScript]); }, [instanceGeneralSetting.additionalScript]);
// Dynamic update metadata with customized profile. // Dynamic update metadata with customized profile
useEffect(() => { useEffect(() => {
if (!instanceGeneralSetting.customProfile) { if (!instanceGeneralSetting.customProfile) {
return; return;
...@@ -56,7 +55,11 @@ const App = observer(() => { ...@@ -56,7 +55,11 @@ const App = observer(() => {
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp"; link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
}, [instanceGeneralSetting.customProfile]); }, [instanceGeneralSetting.customProfile]);
return <Outlet />; return (
}); <MemoFilterProvider>
<Outlet />
</MemoFilterProvider>
);
};
export default App; export default App;
import dayjs from "dayjs"; import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import type { ActivityCalendarProps } from "@/types/statistics"; import type { ActivityCalendarProps } from "@/types/statistics";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell"; import { CalendarCell } from "./CalendarCell";
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared"; import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
import { useCalendarMatrix } from "./useCalendarMatrix"; import { useCalendarMatrix } from "./useCalendarMatrix";
export const ActivityCalendar = memo( export const ActivityCalendar = memo((props: ActivityCalendarProps) => {
observer((props: ActivityCalendarProps) => { const t = useTranslate();
const t = useTranslate(); const { month, selectedDate, data, onClick } = props;
const { month, selectedDate, data, onClick } = props; const { generalSetting } = useInstance();
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset; const weekStartDayOffset = generalSetting.weekStartDayOffset;
const today = useTodayDate(); const today = useTodayDate();
const weekDaysRaw = useWeekdayLabels(); const weekDaysRaw = useWeekdayLabels();
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]); const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
const { weeks, weekDays, maxCount } = useCalendarMatrix({ const { weeks, weekDays, maxCount } = useCalendarMatrix({
month, month,
data, data,
weekDays: weekDaysRaw, weekDays: weekDaysRaw,
weekStartDayOffset, weekStartDayOffset,
today, today,
selectedDate: selectedDateFormatted, selectedDate: selectedDateFormatted,
}); });
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="w-full flex flex-col gap-0.5"> <div className="w-full flex flex-col gap-0.5">
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground"> <div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
{weekDays.map((label, index) => ( {weekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80"> <div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
{label} {label}
</div> </div>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-0.5"> <div className="grid grid-cols-7 gap-0.5">
{weeks.map((week, weekIndex) => {weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => { week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t); const tooltipText = getTooltipText(day.count, day.date, t);
return ( return (
<CalendarCell <CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`} key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day} day={day}
maxCount={maxCount} maxCount={maxCount}
tooltipText={tooltipText} tooltipText={tooltipText}
onClick={onClick} onClick={onClick}
/> />
); );
}), }),
)} )}
</div>
</div> </div>
</TooltipProvider> </div>
); </TooltipProvider>
}), );
); });
ActivityCalendar.displayName = "ActivityCalendar"; ActivityCalendar.displayName = "ActivityCalendar";
import { observer } from "mobx-react-lite";
import { memo } from "react"; import { memo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell"; import { CalendarCell } from "./CalendarCell";
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants"; import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
...@@ -9,48 +8,47 @@ import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared"; ...@@ -9,48 +8,47 @@ import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
import type { CompactMonthCalendarProps } from "./types"; import type { CompactMonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendarMatrix"; import { useCalendarMatrix } from "./useCalendarMatrix";
export const CompactMonthCalendar = memo( export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
observer((props: CompactMonthCalendarProps) => { const { month, data, maxCount, size = "default", onClick } = props;
const { month, data, maxCount, size = "default", onClick } = props; const t = useTranslate();
const t = useTranslate(); const { generalSetting } = useInstance();
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset; const weekStartDayOffset = generalSetting.weekStartDayOffset;
const today = useTodayDate(); const today = useTodayDate();
const weekDays = useWeekdayLabels(); const weekDays = useWeekdayLabels();
const { weeks } = useCalendarMatrix({ const { weeks } = useCalendarMatrix({
month, month,
data, data,
weekDays, weekDays,
weekStartDayOffset, weekStartDayOffset,
today, today,
selectedDate: "", selectedDate: "",
}); });
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE; const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
return ( return (
<div className={cn("grid grid-cols-7", sizeConfig.gap)}> <div className={cn("grid grid-cols-7", sizeConfig.gap)}>
{weeks.map((week, weekIndex) => {weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => { week.days.map((day, dayIndex) => {
const tooltipText = getTooltipText(day.count, day.date, t); const tooltipText = getTooltipText(day.count, day.date, t);
return ( return (
<CalendarCell <CalendarCell
key={`${weekIndex}-${dayIndex}-${day.date}`} key={`${weekIndex}-${dayIndex}-${day.date}`}
day={day} day={day}
maxCount={maxCount} maxCount={maxCount}
tooltipText={tooltipText} tooltipText={tooltipText}
onClick={onClick} onClick={onClick}
size={size} size={size}
/> />
); );
}), }),
)} )}
</div> </div>
); );
}), });
);
CompactMonthCalendar.displayName = "CompactMonthCalendar"; CompactMonthCalendar.displayName = "CompactMonthCalendar";
import { observer } from "mobx-react-lite";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
...@@ -10,7 +9,7 @@ interface Props { ...@@ -10,7 +9,7 @@ interface Props {
className?: string; className?: string;
} }
const AuthFooter = observer(({ className }: Props) => { const AuthFooter = ({ className }: Props) => {
const { i18n: i18nInstance } = useTranslation(); const { i18n: i18nInstance } = useTranslation();
const currentLocale = i18nInstance.language as Locale; const currentLocale = i18nInstance.language as Locale;
const currentTheme = getInitialTheme(); const currentTheme = getInitialTheme();
...@@ -29,6 +28,6 @@ const AuthFooter = observer(({ className }: Props) => { ...@@ -29,6 +28,6 @@ const AuthFooter = observer(({ className }: Props) => {
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} /> <ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
</div> </div>
); );
}); };
export default AuthFooter; export default AuthFooter;
...@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; ...@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -17,6 +17,7 @@ interface Props { ...@@ -17,6 +17,7 @@ interface Props {
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) { function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
const t = useTranslate(); const t = useTranslate();
const { mutateAsync: updateUser } = useUpdateUser();
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState("");
...@@ -49,13 +50,13 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro ...@@ -49,13 +50,13 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro
} }
try { try {
await userStore.updateUser( await updateUser({
{ user: {
name: user.name, name: user.name,
password: newPassword, password: newPassword,
}, },
["password"], updateMask: ["password"],
); });
toast(t("message.password-changed")); toast(t("message.password-changed"));
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
......
...@@ -75,7 +75,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) { ...@@ -75,7 +75,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
try { try {
requestState.setLoading(); requestState.setLoading();
const response = await userServiceClient.createPersonalAccessToken({ const response = await userServiceClient.createPersonalAccessToken({
parent: currentUser.name, parent: currentUser?.name,
description: state.description, description: state.description,
expiresInDays: state.expiration, expiresInDays: state.expiration,
}); });
......
...@@ -8,9 +8,9 @@ import { Input } from "@/components/ui/input"; ...@@ -8,9 +8,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { shortcutServiceClient } from "@/connect"; import { shortcutServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import { userStore } from "@/store";
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb"; import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -24,6 +24,7 @@ interface Props { ...@@ -24,6 +24,7 @@ interface Props {
function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) { function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) {
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser(); const user = useCurrentUser();
const { refetchSettings } = useAuth();
const [shortcut, setShortcut] = useState<Shortcut>( const [shortcut, setShortcut] = useState<Shortcut>(
create(ShortcutSchema, { create(ShortcutSchema, {
name: initialShortcut?.name || "", name: initialShortcut?.name || "",
...@@ -66,7 +67,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -66,7 +67,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
requestState.setLoading(); requestState.setLoading();
if (isCreating) { if (isCreating) {
await shortcutServiceClient.createShortcut({ await shortcutServiceClient.createShortcut({
parent: user.name, parent: user?.name,
shortcut: { shortcut: {
name: "", // Will be set by server name: "", // Will be set by server
title: shortcut.title, title: shortcut.title,
...@@ -85,7 +86,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o ...@@ -85,7 +86,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
toast.success("Update shortcut successfully"); toast.success("Update shortcut successfully");
} }
// Refresh shortcuts. // Refresh shortcuts.
await userStore.fetchUserSettings(); await refetchSettings();
requestState.setFinish(); requestState.setFinish();
onSuccess?.(); onSuccess?.();
onOpenChange(false); 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 { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar"; 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 useAsyncEffect from "@/hooks/useAsyncEffect";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; 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 { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -18,7 +17,7 @@ interface Props { ...@@ -18,7 +17,7 @@ interface Props {
notification: UserNotification; notification: UserNotification;
} }
const MemoCommentMessage = observer(({ notification }: Props) => { function MemoCommentMessage({ notification }: Props) {
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined); const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
...@@ -39,18 +38,20 @@ const MemoCommentMessage = observer(({ notification }: Props) => { ...@@ -39,18 +38,20 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
if (activity.payload?.payload?.case === "memoComment") { if (activity.payload?.payload?.case === "memoComment") {
const memoCommentPayload = activity.payload.payload.value; const memoCommentPayload = activity.payload.payload.value;
const memo = await memoStore.getOrFetchMemoByName(memoCommentPayload.relatedMemo, { const memo = await memoServiceClient.getMemo({
skipStore: true, name: memoCommentPayload.relatedMemo,
}); });
setRelatedMemo(memo); setRelatedMemo(memo);
// Fetch the comment memo // Fetch the comment memo
const comment = await memoStore.getOrFetchMemoByName(memoCommentPayload.memo, { const comment = await memoServiceClient.getMemo({
skipStore: true, name: memoCommentPayload.memo,
}); });
setCommentMemo(comment); setCommentMemo(comment);
const sender = await userStore.getOrFetchUser(notification.sender); const sender = await userServiceClient.getUser({
name: notification.sender,
});
setSender(sender); setSender(sender);
setInitialized(true); setInitialized(true);
} }
...@@ -73,20 +74,22 @@ const MemoCommentMessage = observer(({ notification }: Props) => { ...@@ -73,20 +74,22 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
}; };
const handleArchiveMessage = async (silence = false) => { const handleArchiveMessage = async (silence = false) => {
await userStore.updateNotification( await userServiceClient.updateUserNotification({
{ notification: {
name: notification.name, name: notification.name,
status: UserNotification_Status.ARCHIVED, status: UserNotification_Status.ARCHIVED,
}, },
["status"], updateMask: create(FieldMaskSchema, { paths: ["status"] }),
); });
if (!silence) { if (!silence) {
toast.success(t("message.archived-successfully")); toast.success(t("message.archived-successfully"));
} }
}; };
const handleDeleteMessage = async () => { const handleDeleteMessage = async () => {
await userStore.deleteNotification(notification.name); await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully")); toast.success(t("message.deleted-successfully"));
}; };
...@@ -222,6 +225,6 @@ const MemoCommentMessage = observer(({ notification }: Props) => { ...@@ -222,6 +225,6 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
</div> </div>
</div> </div>
); );
}); }
export default MemoCommentMessage; export default MemoCommentMessage;
...@@ -11,7 +11,6 @@ import { ...@@ -11,7 +11,6 @@ import {
SquareCheckIcon, SquareCheckIcon,
TrashIcon, TrashIcon,
} from "lucide-react"; } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -30,7 +29,7 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation"; ...@@ -30,7 +29,7 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation";
import { useMemoActionHandlers } from "./hooks"; import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types"; import type { MemoActionMenuProps } from "./types";
const MemoActionMenu = observer((props: MemoActionMenuProps) => { const MemoActionMenu = (props: MemoActionMenuProps) => {
const { memo, readonly } = props; const { memo, readonly } = props;
const t = useTranslate(); const t = useTranslate();
...@@ -157,6 +156,6 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => { ...@@ -157,6 +156,6 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => {
/> />
</DropdownMenu> </DropdownMenu>
); );
}); };
export default MemoActionMenu; export default MemoActionMenu;
import { useQueryClient } from "@tanstack/react-query";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useCallback } from "react"; import { useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo"; 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 { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -20,25 +23,30 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -20,25 +23,30 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
const t = useTranslate(); const t = useTranslate();
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); 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 isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const memoUpdatedCallback = useCallback(() => { const memoUpdatedCallback = useCallback(() => {
userStore.setStatsStateId(); // Invalidate user stats to trigger refetch
}, []); queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, [queryClient]);
const handleTogglePinMemoBtnClick = useCallback(async () => { const handleTogglePinMemoBtnClick = useCallback(async () => {
try { try {
await memoStore.updateMemo( await updateMemo({
{ update: {
name: memo.name, name: memo.name,
pinned: !memo.pinned, pinned: !memo.pinned,
}, },
["pinned"], updateMask: ["pinned"],
); });
} catch { } catch {
// do nothing // do nothing
} }
}, [memo.name, memo.pinned]); }, [memo.name, memo.pinned, updateMemo]);
const handleEditMemoClick = useCallback(() => { const handleEditMemoClick = useCallback(() => {
onEdit?.(); onEdit?.();
...@@ -49,13 +57,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -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"); const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
try { try {
await memoStore.updateMemo( await updateMemo({
{ update: {
name: memo.name, name: memo.name,
state, state,
}, },
["state"], updateMask: ["state"],
); });
toast.success(message); toast.success(message);
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { details?: string }; const err = error as { details?: string };
...@@ -68,16 +76,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -68,16 +76,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
} }
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => { const handleCopyLink = useCallback(() => {
let host = instanceStore.state.profile.instanceUrl; let host = profile.instanceUrl;
if (host === "") { if (host === "") {
host = window.location.origin; host = window.location.origin;
} }
copy(`${host}/${memo.name}`); copy(`${host}/${memo.name}`);
toast.success(t("message.succeed-copy-link")); toast.success(t("message.succeed-copy-link"));
}, [memo.name, t]); }, [memo.name, t, profile.instanceUrl]);
const handleCopyContent = useCallback(() => { const handleCopyContent = useCallback(() => {
copy(memo.content); copy(memo.content);
...@@ -89,13 +97,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -89,13 +97,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
}, [setDeleteDialogOpen]); }, [setDeleteDialogOpen]);
const confirmDeleteMemo = useCallback(async () => { const confirmDeleteMemo = useCallback(async () => {
await memoStore.deleteMemo(memo.name); await deleteMemo(memo.name);
toast.success(t("message.deleted-successfully")); toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) { if (isInMemoDetailPage) {
navigateTo("/"); navigateTo("/");
} }
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]); }, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo]);
const handleRemoveCompletedTaskListItemsClick = useCallback(() => { const handleRemoveCompletedTaskListItemsClick = useCallback(() => {
setRemoveTasksDialogOpen(true); setRemoveTasksDialogOpen(true);
...@@ -103,16 +111,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -103,16 +111,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
const confirmRemoveCompletedTaskListItems = useCallback(async () => { const confirmRemoveCompletedTaskListItems = useCallback(async () => {
const newContent = removeCompletedTasks(memo.content); const newContent = removeCompletedTasks(memo.content);
await memoStore.updateMemo( await updateMemo({
{ update: {
name: memo.name, name: memo.name,
content: newContent, content: newContent,
}, },
["content"], updateMask: ["content"],
); });
toast.success(t("message.remove-completed-task-list-items-successfully")); toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, memo.content, t, memoUpdatedCallback]); }, [memo.name, memo.content, t, memoUpdatedCallback, updateMemo]);
return { return {
handleTogglePinMemoBtnClick, handleTogglePinMemoBtnClick,
......
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import hljs from "highlight.js"; import hljs from "highlight.js";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme"; import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { MermaidBlock } from "./MermaidBlock"; import { MermaidBlock } from "./MermaidBlock";
import { extractCodeContent, extractLanguage } from "./utils"; import { extractCodeContent, extractLanguage } from "./utils";
...@@ -14,7 +13,8 @@ interface CodeBlockProps { ...@@ -14,7 +13,8 @@ interface CodeBlockProps {
className?: string; 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 [copied, setCopied] = useState(false);
const codeElement = children as React.ReactElement; const codeElement = children as React.ReactElement;
...@@ -33,7 +33,7 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP ...@@ -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 resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark"); const isDarkTheme = resolvedTheme.includes("dark");
...@@ -131,4 +131,4 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP ...@@ -131,4 +131,4 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP
</div> </div>
</pre> </pre>
); );
}); };
import mermaid from "mermaid"; import mermaid from "mermaid";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { userStore } from "@/store";
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme"; import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
import { extractCodeContent } from "./utils"; import { extractCodeContent } from "./utils";
...@@ -15,7 +14,8 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => { ...@@ -15,7 +14,8 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => {
return appTheme === "default-dark" ? "dark" : "default"; 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 containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>(""); const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
...@@ -23,9 +23,9 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps ...@@ -23,9 +23,9 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps
const codeContent = extractCodeContent(children); 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 // 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) // Resolve theme to actual value (handles "system" theme + system theme changes)
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]); const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
...@@ -90,4 +90,4 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps ...@@ -90,4 +90,4 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps
dangerouslySetInnerHTML={{ __html: svg }} dangerouslySetInnerHTML={{ __html: svg }}
/> />
); );
}); };
import { useContext } from "react"; import { useContext } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { memoFilterStore } from "@/store";
import { MemoFilter, stringifyFilters } from "@/store/memoFilter";
import { MemoContentContext } from "./MemoContentContext"; import { MemoContentContext } from "./MemoContentContext";
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> { interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
...@@ -17,6 +16,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -17,6 +16,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
const context = useContext(MemoContentContext); const context = useContext(MemoContentContext);
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const tag = dataTag || ""; const tag = dataTag || "";
...@@ -37,13 +37,13 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa ...@@ -37,13 +37,13 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
return; return;
} }
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag); const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) { if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag); removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else { } else {
// Remove all existing tag filters first, then add the new one // Remove all existing tag filters first, then add the new one
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch"); removeFilter((f: MemoFilter) => f.factor === "tagSearch");
memoFilterStore.addFilter({ addFilter({
factor: "tagSearch", factor: "tagSearch",
value: tag, value: tag,
}); });
......
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useRef } from "react"; import { useContext, useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox"; 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 { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { MemoContentContext } from "./MemoContentContext"; import { MemoContentContext } from "./MemoContentContext";
...@@ -12,6 +14,8 @@ interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> ...@@ -12,6 +14,8 @@ interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => { export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
const context = useContext(MemoContentContext); const context = useContext(MemoContentContext);
const checkboxRef = useRef<HTMLButtonElement>(null); const checkboxRef = useRef<HTMLButtonElement>(null);
const queryClient = useQueryClient();
const { mutate: updateMemo } = useUpdateMemo();
const handleChange = async (newChecked: boolean) => { const handleChange = async (newChecked: boolean) => {
// Don't update if readonly or no memo context // Don't update if readonly or no memo context
...@@ -49,19 +53,19 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) ...@@ -49,19 +53,19 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
} }
// Update memo content using the string manipulation utility // 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) { if (!memo) {
return; return;
} }
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked); const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
await memoStore.updateMemo( updateMemo({
{ update: {
name: memo.name, name: memo.name,
content: newContent, content: newContent,
}, },
["content"], updateMask: ["content"],
); });
}; };
// Override the disabled prop from remark-gfm (which defaults to true) // 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 { memo } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
...@@ -8,8 +8,9 @@ import remarkBreaks from "remark-breaks"; ...@@ -8,8 +8,9 @@ import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils"; 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 { useTranslate } from "@/utils/i18n";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
...@@ -24,16 +25,17 @@ import { Tag } from "./Tag"; ...@@ -24,16 +25,17 @@ import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem"; import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types"; import type { MemoContentProps } from "./types";
const MemoContent = observer((props: MemoContentProps) => { const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props; const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const queryClient = useQueryClient();
const { const {
containerRef: memoContentContainerRef, containerRef: memoContentContainerRef,
mode: showCompactMode, mode: showCompactMode,
toggle: toggleCompactMode, toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact)); } = 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 allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
const contextValue = { const contextValue = {
...@@ -94,6 +96,6 @@ const MemoContent = observer((props: MemoContentProps) => { ...@@ -94,6 +96,6 @@ const MemoContent = observer((props: MemoContentProps) => {
</div> </div>
</MemoContentContext.Provider> </MemoContentContext.Provider>
); );
}); };
export default memo(MemoContent); export default memo(MemoContent);
import { Settings2Icon } from "lucide-react"; import { Settings2Icon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { viewStore } from "@/store";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
...@@ -10,9 +9,10 @@ interface Props { ...@@ -10,9 +9,10 @@ interface Props {
className?: string; className?: string;
} }
const MemoDisplaySettingMenu = observer(({ className }: Props) => { function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate(); 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 ( return (
<Popover> <Popover>
...@@ -24,12 +24,12 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => { ...@@ -24,12 +24,12 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
<div className="w-full flex flex-row justify-between items-center"> <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> <span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.direction")}</span>
<Select <Select
value={viewStore.state.orderByTimeAsc.toString()} value={orderByTimeAsc.toString()}
onValueChange={(value) => onValueChange={(value) => {
viewStore.state.setPartial({ if ((value === "true") !== orderByTimeAsc) {
orderByTimeAsc: value === "true", toggleSortOrder();
}) }
} }}
> >
<SelectTrigger size="sm"> <SelectTrigger size="sm">
<SelectValue /> <SelectValue />
...@@ -42,14 +42,7 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => { ...@@ -42,14 +42,7 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
</div> </div>
<div className="w-full flex flex-row justify-between items-center"> <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> <span className="text-sm shrink-0 mr-3 text-foreground">{t("common.layout")}</span>
<Select <Select value={layout} onValueChange={(value) => setLayout(value as "LIST" | "MASONRY")}>
value={viewStore.state.layout}
onValueChange={(value) =>
viewStore.state.setPartial({
layout: value as "LIST" | "MASONRY",
})
}
>
<SelectTrigger size="sm"> <SelectTrigger size="sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
...@@ -63,6 +56,6 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => { ...@@ -63,6 +56,6 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}); }
export default MemoDisplaySettingMenu; export default MemoDisplaySettingMenu;
import { observer } from "mobx-react-lite";
import type { EditorRefActions } from "."; import type { EditorRefActions } from ".";
import type { Command } from "./commands"; import type { Command } from "./commands";
import { SuggestionsPopup } from "./SuggestionsPopup"; import { SuggestionsPopup } from "./SuggestionsPopup";
...@@ -10,7 +9,7 @@ interface SlashCommandsProps { ...@@ -10,7 +9,7 @@ interface SlashCommandsProps {
commands: Command[]; commands: Command[];
} }
const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCommandsProps) => { const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef, editorRef,
editorActions, editorActions,
...@@ -43,6 +42,6 @@ const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCom ...@@ -43,6 +42,6 @@ const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCom
)} )}
/> />
); );
}); };
export default SlashCommands; export default SlashCommands;
import { observer } from "mobx-react-lite";
import { useMemo } from "react"; import { useMemo } from "react";
import { matchPath } from "react-router-dom";
import OverflowTip from "@/components/kit/OverflowTip"; import OverflowTip from "@/components/kit/OverflowTip";
import { userStore } from "@/store"; import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import type { EditorRefActions } from "."; import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup"; import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions"; import { useSuggestions } from "./useSuggestions";
...@@ -11,12 +12,16 @@ interface TagSuggestionsProps { ...@@ -11,12 +12,16 @@ interface TagSuggestionsProps {
editorActions: React.ForwardedRef<EditorRefActions>; 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(() => { 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])) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag); .map(([tag]) => tag);
}, [userStore.state.tagCount]); }, [tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({ const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef, editorRef,
...@@ -47,6 +52,4 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro ...@@ -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 { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants"; import { EDITOR_HEIGHT } from "../constants";
import { editorCommands } from "./commands"; import { editorCommands } from "./commands";
...@@ -48,99 +48,116 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef< ...@@ -48,99 +48,116 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
} = props; } = props;
const editorRef = useRef<HTMLTextAreaElement>(null); 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(() => { useEffect(() => {
if (editorRef.current && initialContent) { if (editorRef.current && initialContent) {
editorRef.current.value = initialContent; editorRef.current.value = initialContent;
handleContentChangeCallback(initialContent); handleContentChangeCallback(initialContent);
updateEditorHeight(); updateEditorHeight();
} }
// Only run once on mount to set initial content
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const updateEditorHeight = () => { // Update editor when content is externally changed (e.g., reset after save)
if (editorRef.current) { useEffect(() => {
editorRef.current.style.height = "auto"; if (editorRef.current && editorRef.current.value !== initialContent) {
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px"; 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) { if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value); handleContentChangeCallback(editorRef.current.value);
updateEditorHeight(); updateEditorHeight();
} }
}; }, [handleContentChangeCallback, 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();
}, []);
// Auto-complete markdown lists when pressing Enter // Auto-complete markdown lists when pressing Enter
useListCompletion({ useListCompletion({
......
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es"; import { uniqBy } from "lodash-es";
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import type { LocalFile } from "@/components/memo-metadata"; import type { LocalFile } from "@/components/memo-metadata";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -30,7 +29,7 @@ interface Props { ...@@ -30,7 +29,7 @@ interface Props {
onToggleFocusMode?: () => void; onToggleFocusMode?: () => void;
} }
const InsertMenu = observer((props: Props) => { const InsertMenu = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const context = useContext(MemoEditorContext); const context = useContext(MemoEditorContext);
...@@ -221,6 +220,6 @@ const InsertMenu = observer((props: Props) => { ...@@ -221,6 +220,6 @@ const InsertMenu = observer((props: Props) => {
/> />
</> </>
); );
}); };
export default InsertMenu; export default InsertMenu;
...@@ -3,8 +3,8 @@ import { useState } from "react"; ...@@ -3,8 +3,8 @@ import { useState } from "react";
import useDebounce from "react-use/lib/useDebounce"; import useDebounce from "react-use/lib/useDebounce";
import { memoServiceClient } from "@/connect"; import { memoServiceClient } from "@/connect";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import { extractUserIdFromName } from "@/helpers/resource-names";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { extractUserIdFromName } from "@/store/common";
import { import {
Memo, Memo,
MemoRelation, MemoRelation,
...@@ -37,7 +37,7 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR ...@@ -37,7 +37,7 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
setIsFetching(true); setIsFetching(true);
try { try {
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`]; const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`];
if (searchText) { if (searchText) {
conditions.push(`content.contains("${searchText}")`); conditions.push(`content.contains("${searchText}")`);
} }
......
import { observer } from "mobx-react-lite"; import { useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components"; import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
...@@ -23,7 +24,7 @@ export interface MemoEditorProps { ...@@ -23,7 +24,7 @@ export interface MemoEditorProps {
onCancel?: () => void; onCancel?: () => void;
} }
const MemoEditor = observer((props: MemoEditorProps) => { const MemoEditor = (props: MemoEditorProps) => {
const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props; const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props;
return ( return (
...@@ -40,7 +41,7 @@ const MemoEditor = observer((props: MemoEditorProps) => { ...@@ -40,7 +41,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
/> />
</EditorProvider> </EditorProvider>
); );
}); };
const MemoEditorImpl: React.FC<MemoEditorProps> = ({ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
className, className,
...@@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
onCancel, onCancel,
}) => { }) => {
const t = useTranslate(); const t = useTranslate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const editorRef = useRef<EditorRefActions>(null); const editorRef = useRef<EditorRefActions>(null);
const { state, actions, dispatch } = useEditorContext(); const { state, actions, dispatch } = useEditorContext();
...@@ -66,7 +68,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -66,7 +68,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })), setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })),
memoName, memoName,
addLocalFiles: (files: typeof state.localFiles) => { 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)), removeLocalFile: (previewUrl: string) => dispatch(actions.removeLocalFile(previewUrl)),
localFiles: state.localFiles, localFiles: state.localFiles,
...@@ -75,10 +79,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -75,10 +79,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
); );
// Initialize editor (load memo or cache) // 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 // Auto-save content to localStorage
useAutoSave(state.content, currentUser.name, cacheKey); useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
// Focus mode management with body scroll lock // Focus mode management with body scroll lock
useFocusMode(state.ui.isFocusMode); useFocusMode(state.ui.isFocusMode);
...@@ -91,6 +95,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -91,6 +95,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
useKeyboard(editorRef, { onSave: handleSave, onToggleFocusMode: handleToggleFocusMode }); useKeyboard(editorRef, { onSave: handleSave, onToggleFocusMode: handleToggleFocusMode });
async function handleSave() { async function handleSave() {
// Validate before saving
const { valid, reason } = validationService.canSave(state); const { valid, reason } = validationService.canSave(state);
if (!valid) { if (!valid) {
toast.error(reason || "Cannot save"); toast.error(reason || "Cannot save");
...@@ -108,19 +113,26 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -108,19 +113,26 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
return; return;
} }
// Clear cache on successful save // Clear localStorage cache on successful save
cacheService.clear(cacheService.key(currentUser.name, cacheKey)); 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()); dispatch(actions.reset());
// Notify parent // Notify parent component of successful save
onConfirm?.(result.memoName); onConfirm?.(result.memoName);
toast.success("Saved successfully"); toast.success("Saved successfully");
} catch (error) { } catch (error) {
const message = errorService.handle(error, t); const errorMessage = errorService.getErrorMessage(error);
toast.error(message); toast.error(errorMessage);
console.error("Failed to save memo:", error);
} finally { } finally {
dispatch(actions.setLoading("saving", false)); 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 = { export const errorService = {
handle(error: unknown, t: (key: Translations, params?: Record<string, any>) => string): string { getErrorMessage(error: unknown): string {
if (error instanceof EditorError) { // Handle ConnectError or errors with details property
// Try to get localized error message
const key = `editor.error.${error.code.toLowerCase()}` as Translations;
return t(key, { details: error.details });
}
if (error && typeof error === "object" && "details" in error) { 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) { if (error instanceof Error) {
......
import { create } from "@bufbuild/protobuf"; 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 { isEqual } from "lodash-es";
import { memoServiceClient } from "@/connect"; import { memoServiceClient } from "@/connect";
import { memoStore } from "@/store";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import type { EditorState } from "../state"; import type { EditorState } from "../state";
import { EditorError } from "./errorService";
import { uploadService } from "./uploadService"; import { uploadService } from "./uploadService";
function buildUpdateMask( function buildUpdateMask(
...@@ -73,91 +71,73 @@ export const memoService = { ...@@ -73,91 +71,73 @@ export const memoService = {
parentMemoName?: string; parentMemoName?: string;
}, },
): Promise<{ memoName: string; hasChanges: boolean }> { ): Promise<{ memoName: string; hasChanges: boolean }> {
try { // 1. Upload local files first
// 1. Upload local files first const newAttachments = await uploadService.uploadFiles(state.localFiles);
const newAttachments = await uploadService.uploadFiles(state.localFiles); const allAttachments = [...state.metadata.attachments, ...newAttachments];
const allAttachments = [...state.metadata.attachments, ...newAttachments];
// 2. Update existing memo // 2. Update existing memo
if (options.memoName) { if (options.memoName) {
const prevMemo = await memoStore.getOrFetchMemoByName(options.memoName); const prevMemo = await memoServiceClient.getMemo({ name: options.memoName });
if (!prevMemo) { const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
throw new EditorError("SAVE_FAILED", "Memo not found");
}
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments); if (mask.size === 0) {
return { memoName: prevMemo.name, hasChanges: false };
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 };
} }
// 3. Create new memo or comment const memo = await memoServiceClient.updateMemo({
const memoData = create(MemoSchema, { memo: create(MemoSchema, patch as Record<string, unknown>),
content: state.content, updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }),
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 memoStore.createMemo(memoData);
return { memoName: memo.name, hasChanges: true }; 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> { async load(memoName: string): Promise<EditorState> {
try { const memo = await memoServiceClient.getMemo({ name: memoName });
const memo = await memoStore.getOrFetchMemoByName(memoName);
if (!memo) {
throw new EditorError("LOAD_FAILED", "Memo not found");
}
return { return {
content: memo.content, content: memo.content,
metadata: { metadata: {
visibility: memo.visibility, visibility: memo.visibility,
attachments: memo.attachments, attachments: memo.attachments,
relations: memo.relations, relations: memo.relations,
location: memo.location, location: memo.location,
}, },
ui: { ui: {
isFocusMode: false, isFocusMode: false,
isLoading: { isLoading: {
saving: false, saving: false,
uploading: false, uploading: false,
loading: false, loading: false,
},
isDragging: false,
isComposing: false,
}, },
timestamps: { isDragging: false,
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined, isComposing: false,
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, },
}, timestamps: {
localFiles: [], createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
}; updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
} catch (error) { },
if (error instanceof EditorError) { localFiles: [],
throw error; };
}
throw new EditorError("LOAD_FAILED", error);
}
}, },
}; };
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import type { LocalFile } from "@/components/memo-metadata"; 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 type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { AttachmentSchema } 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 = { export const uploadService = {
async uploadFiles(localFiles: LocalFile[]): Promise<Attachment[]> { async uploadFiles(localFiles: LocalFile[]): Promise<Attachment[]> {
if (localFiles.length === 0) return []; if (localFiles.length === 0) return [];
try { const attachments: Attachment[] = [];
const attachments: Attachment[] = [];
for (const { file } of localFiles) { for (const { file } of localFiles) {
const buffer = new Uint8Array(await file.arrayBuffer()); const buffer = new Uint8Array(await file.arrayBuffer());
const attachment = await attachmentStore.createAttachment( const attachment = await attachmentServiceClient.createAttachment({
create(AttachmentSchema, { attachment: create(AttachmentSchema, {
filename: file.name, filename: file.name,
size: BigInt(file.size), size: BigInt(file.size),
type: file.type, type: file.type,
content: buffer, content: buffer,
}), }),
); });
attachments.push(attachment); attachments.push(attachment);
}
return attachments;
} catch (error) {
throw new EditorError("UPLOAD_FAILED", error);
} }
return attachments;
}, },
}; };
import { observer } from "mobx-react-lite";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
...@@ -63,7 +62,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures ...@@ -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 { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
...@@ -88,6 +87,6 @@ const MemoExplorer = observer((props: Props) => { ...@@ -88,6 +87,6 @@ const MemoExplorer = observer((props: Props) => {
</div> </div>
</aside> </aside>
); );
}); };
export default MemoExplorer; export default MemoExplorer;
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react"; import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react";
import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { shortcutServiceClient } from "@/connect"; 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 { cn } from "@/lib/utils";
import { userStore } from "@/store";
import memoFilterStore from "@/store/memoFilter";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb"; import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import CreateShortcutDialog from "../CreateShortcutDialog"; import CreateShortcutDialog from "../CreateShortcutDialog";
...@@ -23,16 +21,17 @@ const getShortcutId = (name: string): string => { ...@@ -23,16 +21,17 @@ const getShortcutId = (name: string): string => {
return parts.length === 4 ? parts[3] : ""; return parts.length === 4 ? parts[3] : "";
}; };
const ShortcutsSection = observer(() => { function ShortcutsSection() {
const t = useTranslate(); const t = useTranslate();
const shortcuts = userStore.state.shortcuts; const { shortcuts, refetchSettings } = useAuth();
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false); const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>(); const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>(); const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
useAsyncEffect(async () => { useEffect(() => {
await userStore.fetchUserSettings(); refetchSettings();
}, []); }, [refetchSettings]);
const handleDeleteShortcut = async (shortcut: Shortcut) => { const handleDeleteShortcut = async (shortcut: Shortcut) => {
setDeleteTarget(shortcut); setDeleteTarget(shortcut);
...@@ -41,7 +40,7 @@ const ShortcutsSection = observer(() => { ...@@ -41,7 +40,7 @@ const ShortcutsSection = observer(() => {
const confirmDeleteShortcut = async () => { const confirmDeleteShortcut = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name }); await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await userStore.fetchUserSettings(); await refetchSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title })); toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined); setDeleteTarget(undefined);
}; };
...@@ -82,7 +81,7 @@ const ShortcutsSection = observer(() => { ...@@ -82,7 +81,7 @@ const ShortcutsSection = observer(() => {
const maybeEmoji = shortcut.title.split(" ")[0]; const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined; const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title; const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = memoFilterStore.shortcut === shortcutId; const selected = selectedShortcut === shortcutId;
return ( return (
<div <div
key={shortcutId} key={shortcutId}
...@@ -90,7 +89,7 @@ const ShortcutsSection = observer(() => { ...@@ -90,7 +89,7 @@ const ShortcutsSection = observer(() => {
> >
<span <span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")} 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>} {emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()} {title.trim()}
...@@ -131,6 +130,6 @@ const ShortcutsSection = observer(() => { ...@@ -131,6 +130,6 @@ const ShortcutsSection = observer(() => {
/> />
</div> </div>
); );
}); }
export default ShortcutsSection; export default ShortcutsSection;
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react"; import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree"; import TagTree from "../TagTree";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
...@@ -13,8 +12,9 @@ interface Props { ...@@ -13,8 +12,9 @@ interface Props {
tagCount: Record<string, number>; tagCount: Record<string, number>;
} }
const TagsSection = observer((props: Props) => { const TagsSection = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false); const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false); const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
...@@ -23,13 +23,13 @@ const TagsSection = observer((props: Props) => { ...@@ -23,13 +23,13 @@ const TagsSection = observer((props: Props) => {
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
const handleTagClick = (tag: string) => { 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) { if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag); removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else { } else {
// Remove all existing tag filters first, then add the new one // Remove all existing tag filters first, then add the new one
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch"); removeFilter((f: MemoFilter) => f.factor === "tagSearch");
memoFilterStore.addFilter({ addFilter({
factor: "tagSearch", factor: "tagSearch",
value: tag, value: tag,
}); });
...@@ -64,7 +64,7 @@ const TagsSection = observer((props: Props) => { ...@@ -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"> <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]) => { {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 ( return (
<div <div
key={tag} key={tag}
...@@ -95,6 +95,6 @@ const TagsSection = observer((props: Props) => { ...@@ -95,6 +95,6 @@ const TagsSection = observer((props: Props) => {
)} )}
</div> </div>
); );
}); };
export default TagsSection; export default TagsSection;
...@@ -11,11 +11,7 @@ import { ...@@ -11,11 +11,7 @@ import {
SearchIcon, SearchIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { observer } from "mobx-react-lite"; import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
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 { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
interface FilterConfig { interface FilterConfig {
...@@ -58,38 +54,12 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = { ...@@ -58,38 +54,12 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
}, },
}; };
const MemoFilters = observer(() => { const MemoFilters = () => {
const t = useTranslate(); const t = useTranslate();
const [searchParams, setSearchParams] = useSearchParams(); const { filters, removeFilter } = useMemoFilterContext();
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 handleRemoveFilter = (filter: MemoFilter) => { const handleRemoveFilter = (filter: MemoFilter) => {
memoFilterStore.removeFilter((f: MemoFilter) => isEqual(f, filter)); removeFilter((f: MemoFilter) => isEqual(f, filter));
}; };
const getFilterDisplayText = (filter: MemoFilter): string => { const getFilterDisplayText = (filter: MemoFilter): string => {
...@@ -129,7 +99,7 @@ const MemoFilters = observer(() => { ...@@ -129,7 +99,7 @@ const MemoFilters = observer(() => {
})} })}
</div> </div>
); );
}); };
MemoFilters.displayName = "MemoFilters"; MemoFilters.displayName = "MemoFilters";
......
import { observer } from "mobx-react-lite";
import { memo } from "react"; import { memo } from "react";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
...@@ -12,7 +11,7 @@ interface Props { ...@@ -12,7 +11,7 @@ interface Props {
reactions: Reaction[]; reactions: Reaction[];
} }
const MemoReactionListView = observer((props: Props) => { const MemoReactionListView = (props: Props) => {
const { memo: memoData, reactions } = props; const { memo: memoData, reactions } = props;
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const reactionGroup = useReactionGroups(reactions); const reactionGroup = useReactionGroups(reactions);
...@@ -30,6 +29,6 @@ const MemoReactionListView = observer((props: Props) => { ...@@ -30,6 +29,6 @@ const MemoReactionListView = observer((props: Props) => {
{!readonly && currentUser && <ReactionSelector memo={memoData} />} {!readonly && currentUser && <ReactionSelector memo={memoData} />}
</div> </div>
); );
}); };
export default memo(MemoReactionListView); export default memo(MemoReactionListView);
import { SmilePlusIcon } from "lucide-react"; import { SmilePlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useReactionActions } from "./hooks"; import { useReactionActions } from "./hooks";
...@@ -13,9 +12,10 @@ interface Props { ...@@ -13,9 +12,10 @@ interface Props {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
} }
const ReactionSelector = observer((props: Props) => { const ReactionSelector = (props: Props) => {
const { memo, className, onOpenChange } = props; const { memo, className, onOpenChange } = props;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { memoRelatedSetting } = useInstance();
const handleOpenChange = (newOpen: boolean) => { const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen); setOpen(newOpen);
...@@ -26,7 +26,6 @@ const ReactionSelector = observer((props: Props) => { ...@@ -26,7 +26,6 @@ const ReactionSelector = observer((props: Props) => {
memo, memo,
onComplete: () => handleOpenChange(false), onComplete: () => handleOpenChange(false),
}); });
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
...@@ -42,7 +41,7 @@ const ReactionSelector = observer((props: Props) => { ...@@ -42,7 +41,7 @@ const ReactionSelector = observer((props: Props) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md"> <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"> <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 <button
type="button" type="button"
key={reactionType} key={reactionType}
...@@ -59,6 +58,6 @@ const ReactionSelector = observer((props: Props) => { ...@@ -59,6 +58,6 @@ const ReactionSelector = observer((props: Props) => {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}); };
export default ReactionSelector; export default ReactionSelector;
import { observer } from "mobx-react-lite";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
...@@ -13,7 +12,7 @@ interface Props { ...@@ -13,7 +12,7 @@ interface Props {
users: User[]; users: User[];
} }
const ReactionView = observer((props: Props) => { const ReactionView = (props: Props) => {
const { memo, reactionType, users } = props; const { memo, reactionType, users } = props;
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username); const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
...@@ -54,6 +53,6 @@ const ReactionView = observer((props: Props) => { ...@@ -54,6 +53,6 @@ const ReactionView = observer((props: Props) => {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
}); };
export default ReactionView; export default ReactionView;
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { memoServiceClient } from "@/connect"; import { memoServiceClient, userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { memoStore, userStore } from "@/store";
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb";
...@@ -15,7 +14,8 @@ export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => { ...@@ -15,7 +14,8 @@ export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
const fetchReactionGroups = async () => { const fetchReactionGroups = async () => {
const newReactionGroup = new Map<string, User[]>(); const newReactionGroup = new Map<string, User[]>();
for (const reaction of reactions) { 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) || []; const users = newReactionGroup.get(reaction.reactionType) || [];
users.push(user); users.push(user);
newReactionGroup.set(reaction.reactionType, uniq(users)); newReactionGroup.set(reaction.reactionType, uniq(users));
...@@ -57,7 +57,8 @@ export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptio ...@@ -57,7 +57,8 @@ export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptio
reaction: { contentId: memo.name, reactionType }, 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 { } catch {
// skip error // skip error
} }
......
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d"; import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { extractMemoIdFromName } from "@/store/common";
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { LinkType, NodeType } from "./types"; import { LinkType, NodeType } from "./types";
import { convertMemoRelationsToGraphData } from "./utils"; import { convertMemoRelationsToGraphData } from "./utils";
......
import { observer } from "mobx-react-lite";
import { memo, useMemo, useRef, useState } from "react"; import { memo, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
...@@ -50,7 +49,7 @@ interface Props { ...@@ -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 { memo: memoData, className } = props;
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
...@@ -157,6 +156,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => { ...@@ -157,6 +156,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
</article> </article>
</MemoViewContext.Provider> </MemoViewContext.Provider>
); );
}); };
export default memo(MemoView); export default memo(MemoView);
import toast from "react-hot-toast"; 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 { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
export const useMemoActions = (memo: Memo) => { export const useMemoActions = (memo: Memo) => {
const t = useTranslate(); const t = useTranslate();
const { mutateAsync: updateMemo } = useUpdateMemo();
const isArchived = memo.state === State.ARCHIVED; const isArchived = memo.state === State.ARCHIVED;
const archiveMemo = async () => { const archiveMemo = async () => {
if (isArchived) return; if (isArchived) return;
try { 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")); toast.success(t("message.archived-successfully"));
userStore.setStatsStateId();
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);
const err = error as { details?: string }; const err = error as { details?: string };
...@@ -23,7 +23,7 @@ export const useMemoActions = (memo: Memo) => { ...@@ -23,7 +23,7 @@ export const useMemoActions = (memo: Memo) => {
const unpinMemo = async () => { const unpinMemo = async () => {
if (!memo.pinned) return; 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 }; return { archiveMemo, unpinMemo };
......
import { useEffect, useState } from "react"; import { useUser } from "@/hooks/useUserQueries";
import { userStore } from "@/store";
export const useMemoCreator = (creatorName: string) => { export const useMemoCreator = (creatorName: string) => {
const [creator, setCreator] = useState(userStore.getUserByName(creatorName)); const { data: creator } = useUser(creatorName);
useEffect(() => {
userStore.getOrFetchUser(creatorName).then(setCreator);
}, [creatorName]);
return creator; return creator;
}; };
import { useState } from "react"; import { useState } from "react";
import { userStore } from "@/store";
export const useMemoEditor = () => { export const useMemoEditor = () => {
const [showEditor, setShowEditor] = useState(false); const [showEditor, setShowEditor] = useState(false);
...@@ -9,7 +8,6 @@ export const useMemoEditor = () => { ...@@ -9,7 +8,6 @@ export const useMemoEditor = () => {
openEditor: () => setShowEditor(true), openEditor: () => setShowEditor(true),
handleEditorConfirm: () => { handleEditorConfirm: () => {
setShowEditor(false); setShowEditor(false);
userStore.setStatsStateId();
}, },
handleEditorCancel: () => setShowEditor(false), handleEditorCancel: () => setShowEditor(false),
}; };
......
import { useCallback } from "react"; import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store";
interface UseMemoHandlersOptions { interface UseMemoHandlersOptions {
memoName: string; memoName: string;
...@@ -13,6 +13,7 @@ interface UseMemoHandlersOptions { ...@@ -13,6 +13,7 @@ interface UseMemoHandlersOptions {
export const useMemoHandlers = (options: UseMemoHandlersOptions) => { export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const { memoName, parentPage, readonly, openEditor, openPreview } = options; const { memoName, parentPage, readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { memoRelatedSetting } = useInstance();
const handleGotoMemoDetailPage = useCallback(() => { const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { state: { from: parentPage } }); navigateTo(`/${memoName}`, { state: { from: parentPage } });
...@@ -34,12 +35,12 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => { ...@@ -34,12 +35,12 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const handleMemoContentDoubleClick = useCallback( const handleMemoContentDoubleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (readonly) return; if (readonly) return;
if (instanceStore.state.memoRelatedSetting.enableDoubleClickEdit) { if (memoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault(); e.preventDefault();
openEditor(); openEditor();
} }
}, },
[readonly, openEditor], [readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
); );
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick }; return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };
......
import { useState } from "react"; import { useState } from "react";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface UseNsfwContentReturn { export interface UseNsfwContentReturn {
...@@ -10,11 +10,11 @@ export interface UseNsfwContentReturn { ...@@ -10,11 +10,11 @@ export interface UseNsfwContentReturn {
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => { export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false); const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting; const { memoRelatedSetting } = useInstance();
const nsfw = const nsfw =
instanceMemoRelatedSetting.enableBlurNsfwContent && memoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`))); memo.tags?.some((tag) => memoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
return { return {
nsfw: nsfw ?? false, nsfw: nsfw ?? false,
......
import { observer } from "mobx-react-lite"; import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
interface Props { interface Props {
...@@ -8,9 +7,9 @@ interface Props { ...@@ -8,9 +7,9 @@ interface Props {
collapsed?: boolean; collapsed?: boolean;
} }
const MemosLogo = observer((props: Props) => { function MemosLogo(props: Props) {
const { collapsed } = props; const { collapsed } = props;
const instanceGeneralSetting = instanceStore.state.generalSetting; const { generalSetting: instanceGeneralSetting } = useInstance();
const title = instanceGeneralSetting.customProfile?.title || "Memos"; const title = instanceGeneralSetting.customProfile?.title || "Memos";
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp"; const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
...@@ -22,6 +21,6 @@ const MemosLogo = observer((props: Props) => { ...@@ -22,6 +21,6 @@ const MemosLogo = observer((props: Props) => {
</div> </div>
</div> </div>
); );
}); }
export default MemosLogo; export default MemosLogo;
import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react"; 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 { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { userStore } from "@/store";
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MemosLogo from "./MemosLogo"; import MemosLogo from "./MemosLogo";
...@@ -24,18 +22,11 @@ interface Props { ...@@ -24,18 +22,11 @@ interface Props {
className?: string; className?: string;
} }
const Navigation = observer((props: Props) => { const Navigation = (props: Props) => {
const { collapsed, className } = props; const { collapsed, className } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { data: notifications = [] } = useNotifications();
useEffect(() => {
if (!currentUser) {
return;
}
userStore.fetchNotifications();
}, []);
const homeNavLink: NavLinkItem = { const homeNavLink: NavLinkItem = {
id: "header-memos", id: "header-memos",
...@@ -61,7 +52,7 @@ const Navigation = observer((props: Props) => { ...@@ -61,7 +52,7 @@ const Navigation = observer((props: Props) => {
title: t("common.attachments"), title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />, 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 = { const inboxNavLink: NavLinkItem = {
id: "header-inbox", id: "header-inbox",
path: Routes.INBOX, path: Routes.INBOX,
...@@ -135,6 +126,6 @@ const Navigation = observer((props: Props) => { ...@@ -135,6 +126,6 @@ const Navigation = observer((props: Props) => {
)} )}
</header> </header>
); );
}); };
export default Navigation; export default Navigation;
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
const NavigationDrawer = observer(() => { const NavigationDrawer = () => {
const location = useLocation(); const location = useLocation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const instanceGeneralSetting = instanceStore.state.generalSetting; const { generalSetting } = useInstance();
const title = instanceGeneralSetting.customProfile?.title || "Memos"; const title = generalSetting.customProfile?.title || "Memos";
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp"; const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
useEffect(() => { useEffect(() => {
setOpen(false); setOpen(false);
...@@ -34,6 +33,6 @@ const NavigationDrawer = observer(() => { ...@@ -34,6 +33,6 @@ const NavigationDrawer = observer(() => {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
}); };
export default NavigationDrawer; export default NavigationDrawer;
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react"; import { LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { setAccessToken } from "@/auth-state"; import { setAccessToken } from "@/auth-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/connect"; import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore } from "@/store";
import { initialUserStore } from "@/store/user";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
const PasswordSignInForm = observer(() => { function PasswordSignInForm() {
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false); const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(instanceStore.state.profile.mode === "demo" ? "demo" : ""); const [username, setUsername] = useState(profile.mode === "demo" ? "demo" : "");
const [password, setPassword] = useState(instanceStore.state.profile.mode === "demo" ? "secret" : ""); const [password, setPassword] = useState(profile.mode === "demo" ? "secret" : "");
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string; const text = e.target.value as string;
...@@ -56,7 +57,7 @@ const PasswordSignInForm = observer(() => { ...@@ -56,7 +57,7 @@ const PasswordSignInForm = observer(() => {
if (response.accessToken) { if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
} }
await initialUserStore(); await initialize();
navigateTo("/"); navigateTo("/");
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);
...@@ -108,6 +109,6 @@ const PasswordSignInForm = observer(() => { ...@@ -108,6 +109,6 @@ const PasswordSignInForm = observer(() => {
</div> </div>
</form> </form>
); );
}); }
export default PasswordSignInForm; export default PasswordSignInForm;
import { SearchIcon } from "lucide-react"; import { SearchIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { memoFilterStore } from "@/store";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MemoDisplaySettingMenu from "./MemoDisplaySettingMenu"; import MemoDisplaySettingMenu from "./MemoDisplaySettingMenu";
const SearchBar = observer(() => { const SearchBar = () => {
const t = useTranslate(); const t = useTranslate();
const { addFilter } = useMemoFilterContext();
const [queryText, setQueryText] = useState(""); const [queryText, setQueryText] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
...@@ -34,7 +34,7 @@ const SearchBar = observer(() => { ...@@ -34,7 +34,7 @@ const SearchBar = observer(() => {
if (trimmedText !== "") { if (trimmedText !== "") {
const words = trimmedText.split(/\s+/); const words = trimmedText.split(/\s+/);
words.forEach((word) => { words.forEach((word) => {
memoFilterStore.addFilter({ addFilter({
factor: "contentSearch", factor: "contentSearch",
value: word, value: word,
}); });
...@@ -58,6 +58,6 @@ const SearchBar = observer(() => { ...@@ -58,6 +58,6 @@ const SearchBar = observer(() => {
<MemoDisplaySettingMenu className="absolute right-2 top-2 text-sidebar-foreground" /> <MemoDisplaySettingMenu className="absolute right-2 top-2 text-sidebar-foreground" />
</div> </div>
); );
}); };
export default SearchBar; export default SearchBar;
...@@ -30,13 +30,13 @@ const AccessTokenSection = () => { ...@@ -30,13 +30,13 @@ const AccessTokenSection = () => {
const [deleteTarget, setDeleteTarget] = useState<PersonalAccessToken | undefined>(undefined); const [deleteTarget, setDeleteTarget] = useState<PersonalAccessToken | undefined>(undefined);
useEffect(() => { useEffect(() => {
listAccessTokens(currentUser.name).then((tokens) => { listAccessTokens(currentUser?.name ?? "").then((tokens) => {
setPersonalAccessTokens(tokens); setPersonalAccessTokens(tokens);
}); });
}, []); }, []);
const handleCreateAccessTokenDialogConfirm = async (response: CreatePersonalAccessTokenResponse) => { const handleCreateAccessTokenDialogConfirm = async (response: CreatePersonalAccessTokenResponse) => {
const tokens = await listAccessTokens(currentUser.name); const tokens = await listAccessTokens(currentUser?.name ?? "");
setPersonalAccessTokens(tokens); setPersonalAccessTokens(tokens);
// Copy the token to clipboard - this is the only time it will be shown // Copy the token to clipboard - this is the only time it will be shown
if (response.token) { if (response.token) {
......
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -8,9 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ ...@@ -8,9 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { identityProviderServiceClient } from "@/connect"; import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import useDialog from "@/hooks/useDialog"; 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 { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
import { import {
InstanceSetting_GeneralSetting, InstanceSetting_GeneralSetting,
...@@ -24,27 +22,16 @@ import SettingGroup from "./SettingGroup"; ...@@ -24,27 +22,16 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow"; import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
// Helper to extract general setting value from InstanceSetting oneof const InstanceSection = () => {
function getGeneralSetting(setting: any): InstanceSetting_GeneralSetting | undefined {
if (setting?.value?.case === "generalSetting") {
return setting.value.value;
}
return undefined;
}
const InstanceSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const customizeDialog = useDialog(); const customizeDialog = useDialog();
const originalSetting = create( const { generalSetting: originalSetting, profile, updateSetting, fetchSetting } = useInstance();
InstanceSetting_GeneralSettingSchema,
getGeneralSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)) || {},
);
const [instanceGeneralSetting, setInstanceGeneralSetting] = useState<InstanceSetting_GeneralSetting>(originalSetting); const [instanceGeneralSetting, setInstanceGeneralSetting] = useState<InstanceSetting_GeneralSetting>(originalSetting);
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]); const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
useEffect(() => { useEffect(() => {
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile }); setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)]); }, [originalSetting]);
const handleUpdateCustomizedProfileButtonClick = () => { const handleUpdateCustomizedProfileButtonClick = () => {
customizeDialog.open(); customizeDialog.open();
...@@ -61,15 +48,16 @@ const InstanceSection = observer(() => { ...@@ -61,15 +48,16 @@ const InstanceSection = observer(() => {
const handleSaveGeneralSetting = async () => { const handleSaveGeneralSetting = async () => {
try { try {
await instanceStore.upsertInstanceSetting( await updateSetting(
create(InstanceSettingSchema, { create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL), name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.GENERAL]}`,
value: { value: {
case: "generalSetting", case: "generalSetting",
value: instanceGeneralSetting, value: instanceGeneralSetting,
}, },
}), }),
); );
await fetchSetting(InstanceSetting_Key.GENERAL);
} catch (error: any) { } catch (error: any) {
toast.error(error.message); toast.error(error.message);
console.error(error); console.error(error);
...@@ -122,7 +110,7 @@ const InstanceSection = observer(() => { ...@@ -122,7 +110,7 @@ const InstanceSection = observer(() => {
<SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator> <SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator>
<SettingRow label={t("setting.instance-section.disallow-user-registration")}> <SettingRow label={t("setting.instance-section.disallow-user-registration")}>
<Switch <Switch
disabled={instanceStore.state.profile.mode === "demo"} disabled={profile.mode === "demo"}
checked={instanceGeneralSetting.disallowUserRegistration} checked={instanceGeneralSetting.disallowUserRegistration}
onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })} onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })}
/> />
...@@ -130,10 +118,7 @@ const InstanceSection = observer(() => { ...@@ -130,10 +118,7 @@ const InstanceSection = observer(() => {
<SettingRow label={t("setting.instance-section.disallow-password-auth")}> <SettingRow label={t("setting.instance-section.disallow-password-auth")}>
<Switch <Switch
disabled={ disabled={profile.mode === "demo" || (identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)}
instanceStore.state.profile.mode === "demo" ||
(identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)
}
checked={instanceGeneralSetting.disallowPasswordAuth} checked={instanceGeneralSetting.disallowPasswordAuth}
onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })} onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })}
/> />
...@@ -188,6 +173,6 @@ const InstanceSection = observer(() => { ...@@ -188,6 +173,6 @@ const InstanceSection = observer(() => {
/> />
</SettingSection> </SettingSection>
); );
}); };
export default InstanceSection; export default InstanceSection;
...@@ -2,15 +2,14 @@ import { create } from "@bufbuild/protobuf"; ...@@ -2,15 +2,14 @@ import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { MoreVerticalIcon, PlusIcon } from "lucide-react"; import { MoreVerticalIcon, PlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect"; import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog"; 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 { State } from "@/types/proto/api/v1/common_pb";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb"; import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
...@@ -19,10 +18,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge ...@@ -19,10 +18,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable"; import SettingTable from "./SettingTable";
const MemberSection = observer(() => { const MemberSection = () => {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const [users, setUsers] = useState<User[]>([]); const { data: users = [], refetch: refetchUsers } = useListUsers();
const deleteUserMutation = useDeleteUser();
const createDialog = useDialog(); const createDialog = useDialog();
const editDialog = useDialog(); const editDialog = useDialog();
const [editingUser, setEditingUser] = useState<User | undefined>(); const [editingUser, setEditingUser] = useState<User | undefined>();
...@@ -30,15 +30,6 @@ const MemberSection = observer(() => { ...@@ -30,15 +30,6 @@ const MemberSection = observer(() => {
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined); const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = 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) => { const stringifyUserRole = (role: User_Role) => {
if (role === User_Role.HOST) { if (role === User_Role.HOST) {
return "Host"; return "Host";
...@@ -75,7 +66,7 @@ const MemberSection = observer(() => { ...@@ -75,7 +66,7 @@ const MemberSection = observer(() => {
}); });
setArchiveTarget(undefined); setArchiveTarget(undefined);
toast.success(t("setting.member-section.archive-success", { username })); toast.success(t("setting.member-section.archive-success", { username }));
await fetchUsers(); await refetchUsers();
}; };
const handleRestoreUserClick = async (user: User) => { const handleRestoreUserClick = async (user: User) => {
...@@ -88,7 +79,7 @@ const MemberSection = observer(() => { ...@@ -88,7 +79,7 @@ const MemberSection = observer(() => {
updateMask: create(FieldMaskSchema, { paths: ["state"] }), updateMask: create(FieldMaskSchema, { paths: ["state"] }),
}); });
toast.success(t("setting.member-section.restore-success", { username })); toast.success(t("setting.member-section.restore-success", { username }));
await fetchUsers(); await refetchUsers();
}; };
const handleDeleteUserClick = async (user: User) => { const handleDeleteUserClick = async (user: User) => {
...@@ -98,10 +89,9 @@ const MemberSection = observer(() => { ...@@ -98,10 +89,9 @@ const MemberSection = observer(() => {
const confirmDeleteUser = async () => { const confirmDeleteUser = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
const { username, name } = deleteTarget; const { username, name } = deleteTarget;
await userStore.deleteUser(name); deleteUserMutation.mutate(name);
setDeleteTarget(undefined); setDeleteTarget(undefined);
toast.success(t("setting.member-section.delete-success", { username })); toast.success(t("setting.member-section.delete-success", { username }));
await fetchUsers();
}; };
return ( return (
...@@ -180,10 +170,10 @@ const MemberSection = observer(() => { ...@@ -180,10 +170,10 @@ const MemberSection = observer(() => {
/> />
{/* Create User Dialog */} {/* Create User Dialog */}
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={fetchUsers} /> <CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={refetchUsers} />
{/* Edit User Dialog */} {/* 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 <ConfirmDialog
open={!!archiveTarget} open={!!archiveTarget}
...@@ -208,6 +198,6 @@ const MemberSection = observer(() => { ...@@ -208,6 +198,6 @@ const MemberSection = observer(() => {
/> />
</SettingSection> </SettingSection>
); );
}); };
export default MemberSection; export default MemberSection;
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { isEqual, uniq } from "lodash-es"; import { isEqual, uniq } from "lodash-es";
import { CheckIcon, X } from "lucide-react"; import { CheckIcon, X } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/store/common";
import { import {
InstanceSetting_Key, InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting, InstanceSetting_MemoRelatedSetting,
...@@ -21,9 +19,9 @@ import SettingGroup from "./SettingGroup"; ...@@ -21,9 +19,9 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow"; import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
const MemoRelatedSettings = observer(() => { const MemoRelatedSettings = () => {
const t = useTranslate(); 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 [memoRelatedSetting, setMemoRelatedSetting] = useState<InstanceSetting_MemoRelatedSetting>(originalSetting);
const [editingReaction, setEditingReaction] = useState<string>(""); const [editingReaction, setEditingReaction] = useState<string>("");
const [editingNsfwTag, setEditingNsfwTag] = useState<string>(""); const [editingNsfwTag, setEditingNsfwTag] = useState<string>("");
...@@ -54,23 +52,23 @@ const MemoRelatedSettings = observer(() => { ...@@ -54,23 +52,23 @@ const MemoRelatedSettings = observer(() => {
setEditingNsfwTag(""); setEditingNsfwTag("");
}; };
const updateSetting = async () => { const handleUpdateSetting = async () => {
if (memoRelatedSetting.reactions.length === 0) { if (memoRelatedSetting.reactions.length === 0) {
toast.error("Reactions must not be empty."); toast.error("Reactions must not be empty.");
return; return;
} }
try { try {
await instanceStore.upsertInstanceSetting( await updateSetting(
create(InstanceSettingSchema, { create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED), name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.MEMO_RELATED]}`,
value: { value: {
case: "memoRelatedSetting", case: "memoRelatedSetting",
value: memoRelatedSetting, value: memoRelatedSetting,
}, },
}), }),
); );
setOriginalSetting(memoRelatedSetting); await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
} catch (error: any) { } catch (error: any) {
toast.error(error.message); toast.error(error.message);
...@@ -179,12 +177,12 @@ const MemoRelatedSettings = observer(() => { ...@@ -179,12 +177,12 @@ const MemoRelatedSettings = observer(() => {
</SettingGroup> </SettingGroup>
<div className="w-full flex justify-end"> <div className="w-full flex justify-end">
<Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={updateSetting}> <Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={handleUpdateSetting}>
{t("common.save")} {t("common.save")}
</Button> </Button>
</div> </div>
</SettingSection> </SettingSection>
); );
}); };
export default MemoRelatedSettings; export default MemoRelatedSettings;
...@@ -29,13 +29,13 @@ const MyAccountSection = () => { ...@@ -29,13 +29,13 @@ const MyAccountSection = () => {
<SettingSection> <SettingSection>
<SettingGroup title={t("setting.account-section.title")}> <SettingGroup title={t("setting.account-section.title")}>
<div className="w-full flex flex-row justify-start items-center gap-3"> <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="flex-1 min-w-0 flex flex-col justify-center items-start gap-1">
<div className="w-full"> <div className="w-full">
<span className="text-lg font-semibold">{user.displayName}</span> <span className="text-lg font-semibold">{user?.displayName}</span>
<span className="ml-2 text-sm text-muted-foreground">@{user.username}</span> <span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
</div> </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>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleEditAccount}> <Button variant="outline" size="sm" onClick={handleEditAccount}>
......
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { observer } from "mobx-react-lite";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb"; import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { loadLocale, useTranslate } from "@/utils/i18n"; import { loadLocale, useTranslate } from "@/utils/i18n";
...@@ -15,26 +15,48 @@ import SettingRow from "./SettingRow"; ...@@ -15,26 +15,48 @@ import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import WebhookSection from "./WebhookSection"; import WebhookSection from "./WebhookSection";
const PreferencesSection = observer(() => { const PreferencesSection = () => {
const t = useTranslate(); 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) => { const handleLocaleSelectChange = async (locale: Locale) => {
// Apply locale immediately for instant UI feedback and persist to localStorage // Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale); loadLocale(locale);
// Persist to user settings // Persist to user settings
await userStore.updateUserGeneralSetting({ locale }, ["locale"]); updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
}; };
const handleDefaultMemoVisibilityChanged = async (value: string) => { const handleDefaultMemoVisibilityChanged = async (value: string) => {
await userStore.updateUserGeneralSetting({ memoVisibility: value }, ["memoVisibility"]); updateUserGeneralSetting(
{ generalSetting: { memoVisibility: value }, updateMask: ["memoVisibility"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
}; };
const handleThemeChange = async (theme: string) => { const handleThemeChange = async (theme: string) => {
// Apply theme immediately for instant UI feedback // Apply theme immediately for instant UI feedback
loadTheme(theme); loadTheme(theme);
// Persist to user settings // 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 // Provide default values if setting is not loaded yet
...@@ -85,6 +107,6 @@ const PreferencesSection = observer(() => { ...@@ -85,6 +107,6 @@ const PreferencesSection = observer(() => {
</SettingGroup> </SettingGroup>
</SettingSection> </SettingSection>
); );
}); };
export default PreferencesSection; export default PreferencesSection;
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { observer } from "mobx-react-lite";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -8,8 +7,7 @@ import { Input } from "@/components/ui/input"; ...@@ -8,8 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/store/common";
import { import {
InstanceSetting_Key, InstanceSetting_Key,
InstanceSetting_StorageSetting, InstanceSetting_StorageSetting,
...@@ -24,41 +22,20 @@ import SettingGroup from "./SettingGroup"; ...@@ -24,41 +22,20 @@ import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow"; import SettingRow from "./SettingRow";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
// Helper to extract storage setting value from InstanceSetting oneof const StorageSection = () => {
function getStorageSetting(setting: any): InstanceSetting_StorageSetting | undefined {
if (setting?.value?.case === "storageSetting") {
return setting.value.value;
}
return undefined;
}
const StorageSection = observer(() => {
const t = useTranslate(); const t = useTranslate();
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>( const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
create( const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
),
);
useEffect(() => { useEffect(() => {
setInstanceStorageSetting( setInstanceStorageSetting(originalSetting);
create( }, [originalSetting]);
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
),
);
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)]);
const allowSaveStorageSetting = useMemo(() => { const allowSaveStorageSetting = useMemo(() => {
if (instanceStorageSetting.uploadSizeLimitMb <= 0) { if (instanceStorageSetting.uploadSizeLimitMb <= 0) {
return false; return false;
} }
const origin = create(
InstanceSetting_StorageSettingSchema,
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
);
if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) { if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) {
if (instanceStorageSetting.filepathTemplate.length === 0) { if (instanceStorageSetting.filepathTemplate.length === 0) {
return false; return false;
...@@ -74,8 +51,8 @@ const StorageSection = observer(() => { ...@@ -74,8 +51,8 @@ const StorageSection = observer(() => {
return false; return false;
} }
} }
return !isEqual(origin, instanceStorageSetting); return !isEqual(originalSetting, instanceStorageSetting);
}, [instanceStorageSetting, instanceStore.state]); }, [instanceStorageSetting, originalSetting]);
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => { const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
let num = parseInt(event.target.value); let num = parseInt(event.target.value);
...@@ -152,16 +129,22 @@ const StorageSection = observer(() => { ...@@ -152,16 +129,22 @@ const StorageSection = observer(() => {
}; };
const saveInstanceStorageSetting = async () => { const saveInstanceStorageSetting = async () => {
await instanceStore.upsertInstanceSetting( try {
create(InstanceSettingSchema, { await updateSetting(
name: buildInstanceSettingName(InstanceSetting_Key.STORAGE), create(InstanceSettingSchema, {
value: { name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
case: "storageSetting", value: {
value: instanceStorageSetting, case: "storageSetting",
}, value: instanceStorageSetting,
}), },
); }),
toast.success("Updated"); );
await fetchSetting(InstanceSetting_Key.STORAGE);
toast.success("Updated");
} catch (error: any) {
toast.error(error.message);
console.error(error);
}
}; };
return ( return (
...@@ -253,6 +236,6 @@ const StorageSection = observer(() => { ...@@ -253,6 +236,6 @@ const StorageSection = observer(() => {
</div> </div>
</SettingSection> </SettingSection>
); );
}); };
export default StorageSection; export default StorageSection;
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import i18n from "@/i18n"; import i18n from "@/i18n";
import type { MonthNavigatorProps } from "@/types/statistics"; 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 currentMonth = dayjs(visibleMonth).toDate();
const handlePrevMonth = () => { const handlePrevMonth = () => {
...@@ -30,4 +29,4 @@ export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNa ...@@ -30,4 +29,4 @@ export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNa
</div> </div>
</div> </div>
); );
}); };
import dayjs from "dayjs"; import dayjs from "dayjs";
import { observer } from "mobx-react-lite";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { CompactMonthCalendar } from "@/components/ActivityCalendar"; import { CompactMonthCalendar } from "@/components/ActivityCalendar";
import { useDateFilterNavigation } from "@/hooks"; import { useDateFilterNavigation } from "@/hooks";
...@@ -13,7 +12,7 @@ interface Props { ...@@ -13,7 +12,7 @@ interface Props {
statisticsData: StatisticsData; statisticsData: StatisticsData;
} }
const StatisticsView = observer((props: Props) => { const StatisticsView = (props: Props) => {
const { statisticsData } = props; const { statisticsData } = props;
const { activityStats } = statisticsData; const { activityStats } = statisticsData;
const navigateToDateFilter = useDateFilterNavigation(); const navigateToDateFilter = useDateFilterNavigation();
...@@ -33,6 +32,6 @@ const StatisticsView = observer((props: Props) => { ...@@ -33,6 +32,6 @@ const StatisticsView = observer((props: Props) => {
</div> </div>
</div> </div>
); );
}); };
export default StatisticsView; export default StatisticsView;
import { ChevronRightIcon, HashIcon } from "lucide-react"; import { ChevronRightIcon, HashIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useToggle from "react-use/lib/useToggle"; import useToggle from "react-use/lib/useToggle";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter"; import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
interface Tag { interface Tag {
key: string; key: string;
...@@ -86,9 +85,10 @@ interface TagItemContainerProps { ...@@ -86,9 +85,10 @@ interface TagItemContainerProps {
expandSubTags: boolean; expandSubTags: boolean;
} }
const TagItemContainer = observer((props: TagItemContainerProps) => { const TagItemContainer = (props: TagItemContainerProps) => {
const { tag, expandSubTags } = props; 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 isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
const hasSubTags = tag.subTags.length > 0; const hasSubTags = tag.subTags.length > 0;
const [showSubTags, toggleSubTags] = useToggle(false); const [showSubTags, toggleSubTags] = useToggle(false);
...@@ -99,11 +99,11 @@ const TagItemContainer = observer((props: TagItemContainerProps) => { ...@@ -99,11 +99,11 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
const handleTagClick = () => { const handleTagClick = () => {
if (isActive) { if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text); removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
} else { } else {
// Remove all existing tag filters first, then add the new one // Remove all existing tag filters first, then add the new one
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch"); removeFilter((f: MemoFilter) => f.factor === "tagSearch");
memoFilterStore.addFilter({ addFilter({
factor: "tagSearch", factor: "tagSearch",
value: tag.text, value: tag.text,
}); });
...@@ -155,6 +155,6 @@ const TagItemContainer = observer((props: TagItemContainerProps) => { ...@@ -155,6 +155,6 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
) : null} ) : null}
</> </>
); );
}); };
export default TagTree; export default TagTree;
...@@ -8,9 +8,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from " ...@@ -8,9 +8,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useInstance } from "@/contexts/InstanceContext";
import { convertFileToBase64 } from "@/helpers/utils"; import { convertFileToBase64 } from "@/helpers/utils";
import useCurrentUser from "@/hooks/useCurrentUser"; 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 { User as UserPb, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
...@@ -32,14 +33,15 @@ interface State { ...@@ -32,14 +33,15 @@ interface State {
function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) { function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { generalSetting: instanceGeneralSetting } = useInstance();
const { mutateAsync: updateUser } = useUpdateUser();
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
avatarUrl: currentUser.avatarUrl, avatarUrl: currentUser?.avatarUrl ?? "",
username: currentUser.username, username: currentUser?.username ?? "",
displayName: currentUser.displayName, displayName: currentUser?.displayName ?? "",
email: currentUser.email, email: currentUser?.email ?? "",
description: currentUser.description, description: currentUser?.description ?? "",
}); });
const instanceGeneralSetting = instanceStore.state.generalSetting;
const handleCloseBtnClick = () => { const handleCloseBtnClick = () => {
onOpenChange(false); onOpenChange(false);
...@@ -112,32 +114,32 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) { ...@@ -112,32 +114,32 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
try { try {
const updateMask = []; const updateMask = [];
if (!isEqual(currentUser.username, state.username)) { if (!isEqual(currentUser?.username, state.username)) {
updateMask.push("username"); updateMask.push("username");
} }
if (!isEqual(currentUser.displayName, state.displayName)) { if (!isEqual(currentUser?.displayName, state.displayName)) {
updateMask.push("display_name"); updateMask.push("display_name");
} }
if (!isEqual(currentUser.email, state.email)) { if (!isEqual(currentUser?.email, state.email)) {
updateMask.push("email"); updateMask.push("email");
} }
if (!isEqual(currentUser.avatarUrl, state.avatarUrl)) { if (!isEqual(currentUser?.avatarUrl, state.avatarUrl)) {
updateMask.push("avatar_url"); updateMask.push("avatar_url");
} }
if (!isEqual(currentUser.description, state.description)) { if (!isEqual(currentUser?.description, state.description)) {
updateMask.push("description"); updateMask.push("description");
} }
await userStore.updateUser( await updateUser({
create(UserSchema, { user: {
name: currentUser.name, name: currentUser?.name,
username: state.username, username: state.username,
displayName: state.displayName, displayName: state.displayName,
email: state.email, email: state.email,
avatarUrl: state.avatarUrl, avatarUrl: state.avatarUrl,
description: state.description, description: state.description,
}), },
updateMask, updateMask,
); });
toast.success(t("message.update-succeed")); toast.success(t("message.update-succeed"));
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
......
...@@ -6,8 +6,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D ...@@ -6,8 +6,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
import { buildInstanceSettingName } from "@/store/common"; import { buildInstanceSettingName } from "@/helpers/resource-names";
import { import {
InstanceSetting_GeneralSetting_CustomProfile, InstanceSetting_GeneralSetting_CustomProfile,
InstanceSetting_GeneralSetting_CustomProfileSchema, InstanceSetting_GeneralSetting_CustomProfileSchema,
...@@ -24,7 +24,7 @@ interface Props { ...@@ -24,7 +24,7 @@ interface Props {
function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) { function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate(); const t = useTranslate();
const instanceGeneralSetting = instanceStore.state.generalSetting; const { generalSetting: instanceGeneralSetting, updateSetting } = useInstance();
const [customProfile, setCustomProfile] = useState<InstanceSetting_GeneralSetting_CustomProfile>( const [customProfile, setCustomProfile] = useState<InstanceSetting_GeneralSetting_CustomProfile>(
create(InstanceSetting_GeneralSetting_CustomProfileSchema, instanceGeneralSetting.customProfile || {}), create(InstanceSetting_GeneralSetting_CustomProfileSchema, instanceGeneralSetting.customProfile || {}),
); );
...@@ -76,7 +76,7 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) ...@@ -76,7 +76,7 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
setIsLoading(true); setIsLoading(true);
try { try {
await instanceStore.upsertInstanceSetting( await updateSetting(
create(InstanceSettingSchema, { create(InstanceSettingSchema, {
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL), name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
value: { 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 { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { userServiceClient } from "@/connect";
import { authServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import i18n, { locales } from "@/i18n"; import i18n, { locales } from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; 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 { getLocaleDisplayName, useTranslate } from "@/utils/i18n";
import { loadTheme, THEME_OPTIONS } from "@/utils/theme"; import { loadTheme, THEME_OPTIONS } from "@/utils/theme";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
...@@ -24,69 +26,107 @@ interface Props { ...@@ -24,69 +26,107 @@ interface Props {
collapsed?: boolean; collapsed?: boolean;
} }
const UserMenu = observer((props: Props) => { const UserMenu = (props: Props) => {
const { collapsed } = props; const { collapsed } = props;
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const generalSetting = userStore.state.userGeneralSetting; const { userGeneralSetting, refetchSettings, logout } = useAuth();
const currentLocale = generalSetting?.locale || "en"; const currentLocale = userGeneralSetting?.locale || "en";
const currentTheme = generalSetting?.theme || "default"; const currentTheme = userGeneralSetting?.theme || "default";
const handleLocaleChange = async (locale: Locale) => { const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
// Apply locale immediately for instant UI feedback // Apply locale immediately for instant UI feedback
i18n.changeLanguage(locale); i18n.changeLanguage(locale);
// Persist to user settings // 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) => { const handleThemeChange = async (theme: string) => {
if (!currentUser) return;
// Apply theme immediately for instant UI feedback // Apply theme immediately for instant UI feedback
loadTheme(theme); loadTheme(theme);
// Persist to user settings // 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 () => { 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) try {
// Preserve app-wide settings like theme // Then clear user-specific localStorage items
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"]; // Preserve app-wide settings like theme
const keysToRemove: string[] = []; const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
if (key && !keysToPreserve.includes(key)) { if (key && !keysToPreserve.includes(key)) {
keysToRemove.push(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 // Always redirect to auth page
// This removes the current page from browser history window.location.href = Routes.AUTH;
window.location.replace(Routes.AUTH);
}; };
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={!currentUser}> <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")}> <div className={cn("w-auto flex flex-row justify-start items-center cursor-pointer text-foreground", collapsed ? "px-1" : "px-3")}>
{currentUser.avatarUrl ? ( {currentUser?.avatarUrl ? (
<UserAvatar className="shrink-0" avatarUrl={currentUser.avatarUrl} /> <UserAvatar className="shrink-0" avatarUrl={currentUser?.avatarUrl} />
) : ( ) : (
<User2Icon className="w-6 mx-auto h-auto text-muted-foreground" /> <User2Icon className="w-6 mx-auto h-auto text-muted-foreground" />
)} )}
{!collapsed && ( {!collapsed && (
<span className="ml-2 text-lg font-medium text-foreground grow truncate"> <span className="ml-2 text-lg font-medium text-foreground grow truncate">
{currentUser.displayName || currentUser.username} {currentUser?.displayName || currentUser?.username}
</span> </span>
)} )}
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser.username)}`)}> <DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
<SquareUserIcon className="size-4 text-muted-foreground" /> <SquareUserIcon className="size-4 text-muted-foreground" />
{t("common.profile")} {t("common.profile")}
</DropdownMenuItem> </DropdownMenuItem>
...@@ -135,6 +175,6 @@ const UserMenu = observer((props: Props) => { ...@@ -135,6 +175,6 @@ const UserMenu = observer((props: Props) => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
}); };
export default UserMenu; export default UserMenu;
import { LinkIcon, XIcon } from "lucide-react"; import { LinkIcon, XIcon } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { extractMemoIdFromName } from "@/store/common";
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb"; import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
......
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { LinkIcon, MilestoneIcon } from "lucide-react"; import { LinkIcon, MilestoneIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { memoServiceClient } from "@/connect";
import { cn } from "@/lib/utils"; 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 { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MetadataCard from "./MetadataCard"; import MetadataCard from "./MetadataCard";
...@@ -17,7 +16,7 @@ interface RelationListProps extends BaseMetadataProps { ...@@ -17,7 +16,7 @@ interface RelationListProps extends BaseMetadataProps {
parentPage?: string; 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 t = useTranslate();
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]); const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing"); const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
...@@ -43,7 +42,7 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh ...@@ -43,7 +42,7 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
(async () => { (async () => {
if (referencingRelations.length > 0) { if (referencingRelations.length > 0) {
const requests = referencingRelations.map(async (relation) => { 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); const list = await Promise.all(requests);
setReferencingMemos(list); setReferencingMemos(list);
...@@ -139,6 +138,6 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh ...@@ -139,6 +138,6 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
)} )}
</MetadataCard> </MetadataCard>
); );
}); }
export default RelationList; export default RelationList;
...@@ -2,8 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; ...@@ -2,8 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect"; import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web"; import { createConnectTransport } from "@connectrpc/connect-web";
import { getAccessToken, setAccessToken } from "./auth-state"; import { getAccessToken, setAccessToken } from "./auth-state";
import { getInstanceConfig } from "./instance-config";
import { ROUTES } from "./router/routes"; import { ROUTES } from "./router/routes";
import { instanceStore } from "./store";
import { ActivityService } from "./types/proto/api/v1/activity_service_pb"; import { ActivityService } from "./types/proto/api/v1/activity_service_pb";
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb"; import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
import { AuthService } from "./types/proto/api/v1/auth_service_pb"; import { AuthService } from "./types/proto/api/v1/auth_service_pb";
...@@ -37,30 +37,28 @@ const ROUTE_CONFIG = { ...@@ -37,30 +37,28 @@ const ROUTE_CONFIG = {
// Token Refresh State Management // Token Refresh State Management
// ============================================================================ // ============================================================================
class TokenRefreshManager { const createTokenRefreshManager = () => {
private isRefreshing = false; let isRefreshing = false;
private refreshPromise: Promise<void> | null = null; let refreshPromise: Promise<void> | null = null;
async refresh(refreshFn: () => Promise<void>): Promise<void> { return {
if (this.isRefreshing && this.refreshPromise) { async refresh(refreshFn: () => Promise<void>): Promise<void> {
return this.refreshPromise; if (isRefreshing && refreshPromise) {
} return refreshPromise;
}
this.isRefreshing = true;
this.refreshPromise = refreshFn().finally(() => {
this.isRefreshing = false;
this.refreshPromise = null;
});
return this.refreshPromise; isRefreshing = true;
} refreshPromise = refreshFn().finally(() => {
isRefreshing = false;
refreshPromise = null;
});
isCurrentlyRefreshing(): boolean { return refreshPromise;
return this.isRefreshing; },
} };
} };
const tokenRefreshManager = new TokenRefreshManager(); const tokenRefreshManager = createTokenRefreshManager();
// ============================================================================ // ============================================================================
// Route Access Control // Route Access Control
...@@ -79,7 +77,7 @@ function getAuthFailureRedirect(currentPath: string): string | null { ...@@ -79,7 +77,7 @@ function getAuthFailureRedirect(currentPath: string): string | null {
return null; return null;
} }
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) { if (getInstanceConfig().memoRelatedSetting.disallowPublicVisibility) {
return ROUTES.AUTH; 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 = () => { const useCurrentUser = () => {
return userStore.state.userMapByName[userStore.state.currentUser || ""]; const { currentUser } = useAuth();
return currentUser;
}; };
export default useCurrentUser; export default useCurrentUser;
import { useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { stringifyFilters } from "@/store/memoFilter"; import { stringifyFilters } from "@/contexts/MemoFilterContext";
export const useDateFilterNavigation = () => { export const useDateFilterNavigation = () => {
const navigate = useNavigate(); const navigate = useNavigate();
......
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { countBy } from "lodash-es"; import { countBy } from "lodash-es";
import { useEffect, useState } from "react"; import { useMemo } from "react";
import { memoStore, userStore } from "@/store"; import { useMemos } from "@/hooks/useMemoQueries";
import { useUserStats } from "@/hooks/useUserQueries";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
export interface FilteredMemoStats { export interface FilteredMemoStats {
...@@ -11,100 +12,68 @@ export interface FilteredMemoStats { ...@@ -11,100 +12,68 @@ export interface FilteredMemoStats {
loading: boolean; loading: boolean;
} }
const getUserStatsKey = (userName: string): string => {
return `${userName}/stats`;
};
export interface UseFilteredMemoStatsOptions { export interface UseFilteredMemoStatsOptions {
userName?: string; userName?: string;
} }
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options; 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(() => { // Fetch user stats if userName is provided
const computeStats = async () => { const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {};
let useBackendStats = false;
// Try to use backend user stats if userName is provided // Fetch memos for fallback computation (or when userName is not provided)
if (userName) { const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({});
// Check if stats are already cached, otherwise fetch them
const statsKey = getUserStatsKey(userName);
let userStats = userStore.state.userStatsByName[statsKey];
if (!userStats) { const data = useMemo(() => {
try { const loading = isLoadingUserStats || isLoadingMemos;
await userStore.fetchUserStats(userName); let activityStats: Record<string, number> = {};
userStats = userStore.state.userStatsByName[statsKey]; let tagCount: Record<string, number> = {};
} catch (error) {
console.error("Failed to fetch user stats:", error);
// Will fall back to computing from cache below
}
}
if (userStats) { // Try to use backend user stats if userName is provided and available
// Use activity timestamps from user stats if (userName && userStats) {
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) { // Use activity timestamps from user stats
activityStats = countBy( if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
userStats.memoDisplayTimestamps activityStats = countBy(
.map((ts) => (ts ? timestampDate(ts) : undefined)) userStats.memoDisplayTimestamps
.filter((date): date is Date => date !== undefined) .map((ts) => (ts ? timestampDate(ts) : undefined))
.map((date) => dayjs(date).format("YYYY-MM-DD")), .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;
}
} }
// Use tag counts from user stats
// Fallback: compute from cached memos if backend stats not available 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 // Also used for Explore and Archived contexts
if (!useBackendStats) { const displayTimeList: Date[] = [];
const displayTimeList: Date[] = []; const memos = memosResponse.memos;
const memos = memoStore.state.memos;
for (const memo of memos) { for (const memo of memos) {
// Collect display timestamps for activity calendar // Collect display timestamps for activity calendar
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
if (displayTime) { if (displayTime) {
displayTimeList.push(displayTime); displayTimeList.push(displayTime);
} }
// Count tags // Count tags
if (memo.tags && memo.tags.length > 0) { if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) { for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1; tagCount[tag] = (tagCount[tag] || 0) + 1;
}
} }
} }
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
} }
setData({ activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
statistics: { activityStats }, }
tags: tagCount,
loading: false,
});
};
computeStats(); return {
}, [memoStoreStateId, userStatsStateId, userName]); statistics: { activityStats },
tags: tagCount,
loading,
};
}, [userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
return data; 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 { useMemo } from "react";
import { instanceStore, userStore } from "@/store"; import { useAuth } from "@/contexts/AuthContext";
import { extractUserIdFromName, getVisibilityName } from "@/store/common"; import { useInstance } from "@/contexts/InstanceContext";
import memoFilterStore from "@/store/memoFilter"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; 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 getShortcutId = (name: string): string => {
const parts = name.split("/"); const parts = name.split("/");
return parts.length === 4 ? parts[3] : ""; return parts.length === 4 ? parts[3] : "";
...@@ -20,10 +37,9 @@ export interface UseMemoFiltersOptions { ...@@ -20,10 +37,9 @@ export interface UseMemoFiltersOptions {
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => { export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {
const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options; const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options;
// Extract MobX observable values to avoid issues with React dependency tracking const { shortcuts } = useAuth();
const currentShortcut = memoFilterStore.shortcut; const { filters, shortcut: currentShortcut } = useMemoFilterContext();
const shortcuts = userStore.state.shortcuts; const { memoRelatedSetting } = useInstance();
const filters = memoFilterStore.filters;
// Get selected shortcut if needed // Get selected shortcut if needed
const selectedShortcut = useMemo(() => { const selectedShortcut = useMemo(() => {
...@@ -31,7 +47,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -31,7 +47,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
return shortcuts.find((shortcut) => getShortcutId(shortcut.name) === currentShortcut); return shortcuts.find((shortcut) => getShortcutId(shortcut.name) === currentShortcut);
}, [includeShortcuts, currentShortcut, shortcuts]); }, [includeShortcuts, currentShortcut, shortcuts]);
// Build filter - wrapped in useMemo but also using observer for reactivity // Build filter
return useMemo(() => { return useMemo(() => {
const conditions: string[] = []; const conditions: string[] = [];
...@@ -45,7 +61,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -45,7 +61,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
conditions.push(selectedShortcut.filter); conditions.push(selectedShortcut.filter);
} }
// Add active filters from memoFilterStore // Add active filters from context
for (const filter of filters) { for (const filter of filters) {
if (filter.factor === "contentSearch") { if (filter.factor === "contentSearch") {
conditions.push(`content.contains("${filter.value}")`); conditions.push(`content.contains("${filter.value}")`);
...@@ -55,7 +71,6 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -55,7 +71,6 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
if (includePinned) { if (includePinned) {
conditions.push(`pinned`); conditions.push(`pinned`);
} }
// Skip pinned filter if not enabled
} else if (filter.factor === "property.hasLink") { } else if (filter.factor === "property.hasLink") {
conditions.push(`has_link`); conditions.push(`has_link`);
} else if (filter.factor === "property.hasTaskList") { } else if (filter.factor === "property.hasTaskList") {
...@@ -63,12 +78,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -63,12 +78,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
} else if (filter.factor === "property.hasCode") { } else if (filter.factor === "property.hasCode") {
conditions.push(`has_code`); conditions.push(`has_code`);
} else if (filter.factor === "displayTime") { } else if (filter.factor === "displayTime") {
// Check instance setting for display time factor const displayWithUpdateTime = memoRelatedSetting?.displayWithUpdateTime ?? false;
const setting = instanceStore.getInstanceSettingByKey(InstanceSetting_Key.MEMO_RELATED);
const displayWithUpdateTime = setting?.value.case === "memoRelatedSetting" ? setting.value.value.displayWithUpdateTime : false;
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts"; const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
// Convert date to UTC timestamp range
const filterDate = new Date(filter.value); const filterDate = new Date(filter.value);
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000; const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
const timestampAfter = filterUtcTimestamp / 1000; const timestampAfter = filterUtcTimestamp / 1000;
...@@ -77,15 +89,12 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un ...@@ -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) { 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(", "); const visibilityValues = visibilities.map((v) => `"${getVisibilityName(v)}"`).join(", ");
conditions.push(`visibility in [${visibilityValues}]`); conditions.push(`visibility in [${visibilityValues}]`);
} }
return conditions.length > 0 ? conditions.join(" && ") : undefined; 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 { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo } from "react"; import { useMemo } from "react";
import { viewStore } from "@/store"; import { useView } from "@/contexts/ViewContext";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb";
...@@ -17,9 +17,7 @@ export interface UseMemoSortingResult { ...@@ -17,9 +17,7 @@ export interface UseMemoSortingResult {
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => { export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
const { pinnedFirst = false, state = State.NORMAL } = options; const { pinnedFirst = false, state = State.NORMAL } = options;
const { orderByTimeAsc } = useView();
// Extract MobX observable values to avoid issues with React dependency tracking
const orderByTimeAsc = viewStore.state.orderByTimeAsc;
// Generate orderBy string for API // Generate orderBy string for API
const orderBy = useMemo(() => { const orderBy = useMemo(() => {
......
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { userStore } from "@/store"; import { useAuth } from "@/contexts/AuthContext";
import { getLocaleWithFallback, loadLocale } from "@/utils/i18n"; import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
/** /**
...@@ -9,7 +9,7 @@ import { getLocaleWithFallback, loadLocale } from "@/utils/i18n"; ...@@ -9,7 +9,7 @@ import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
*/ */
export const useUserLocale = () => { export const useUserLocale = () => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const userGeneralSetting = userStore.state.userGeneralSetting; const { userGeneralSetting } = useAuth();
// Apply locale when user setting changes or user logs in // Apply locale when user setting changes or user logs in
useEffect(() => { 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 { useEffect } from "react";
import { userStore } from "@/store"; import { useAuth } from "@/contexts/AuthContext";
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme"; import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme";
/** /**
...@@ -7,7 +7,7 @@ import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/uti ...@@ -7,7 +7,7 @@ import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/uti
* Priority: User setting → localStorage → system preference * Priority: User setting → localStorage → system preference
*/ */
export const useUserTheme = () => { export const useUserTheme = () => {
const userGeneralSetting = userStore.state.userGeneralSetting; const { userGeneralSetting } = useAuth();
// Apply theme when user setting changes or user logs in // Apply theme when user setting changes or user logs in
useEffect(() => { 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 { useEffect, useMemo, useState } from "react";
import { matchPath, Outlet, useLocation } from "react-router-dom"; import { matchPath, Outlet, useLocation } from "react-router-dom";
import type { MemoExplorerContext } from "@/components/MemoExplorer"; import type { MemoExplorerContext } from "@/components/MemoExplorer";
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer"; import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
import MobileHeader from "@/components/MobileHeader"; import MobileHeader from "@/components/MobileHeader";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats"; import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
import useResponsiveWidth from "@/hooks/useResponsiveWidth"; import useResponsiveWidth from "@/hooks/useResponsiveWidth";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { userStore } from "@/store";
const MainLayout = observer(() => { const MainLayout = () => {
const { md, lg } = useResponsiveWidth(); const { md, lg } = useResponsiveWidth();
const location = useLocation(); const location = useLocation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
...@@ -34,8 +33,8 @@ const MainLayout = observer(() => { ...@@ -34,8 +33,8 @@ const MainLayout = observer(() => {
if (username) { if (username) {
// Fetch or get user to obtain user name (e.g., "users/123") // Fetch or get user to obtain user name (e.g., "users/123")
// Note: User stats will be fetched by useFilteredMemoStats // Note: User stats will be fetched by useFilteredMemoStats
userStore userServiceClient
.getOrFetchUser(`users/${username}`) .getUser({ name: `users/${username}` })
.then((user) => { .then((user) => {
setProfileUserName(user.name); setProfileUserName(user.name);
}) })
...@@ -86,6 +85,6 @@ const MainLayout = observer(() => { ...@@ -86,6 +85,6 @@ const MainLayout = observer(() => {
</div> </div>
</section> </section>
); );
}); };
export default MainLayout; 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 AuthFooter from "@/components/AuthFooter";
import PasswordSignInForm from "@/components/PasswordSignInForm"; import PasswordSignInForm from "@/components/PasswordSignInForm";
import { instanceStore } from "@/store"; import { useInstance } from "@/contexts/InstanceContext";
const AdminSignIn = observer(() => { const AdminSignIn = () => {
const instanceGeneralSetting = instanceStore.state.generalSetting; const { generalSetting: instanceGeneralSetting } = useInstance();
return ( 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"> <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(() => { ...@@ -19,6 +18,6 @@ const AdminSignIn = observer(() => {
<AuthFooter /> <AuthFooter />
</div> </div>
); );
}); };
export default AdminSignIn; 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