From 012405f7fdcf5a7d8476c7f79f5edc9c4edaa163 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 26 Feb 2025 22:58:22 +0800 Subject: [PATCH] refactor: user stats state --- .../components/HomeSidebar/HomeSidebar.tsx | 14 ++--- .../components/HomeSidebar/TagsSection.tsx | 12 ++--- web/src/components/MemoActionMenu.tsx | 6 +-- .../MemoEditor/ActionButton/TagSelector.tsx | 9 ++-- .../MemoEditor/Editor/TagSuggestions.tsx | 9 ++-- web/src/components/MemoView.tsx | 5 +- web/src/components/RenameTagDialog.tsx | 3 -- web/src/components/StatisticsView.tsx | 13 ++--- web/src/pages/UserProfile.tsx | 2 +- web/src/store/v1/index.ts | 1 - web/src/store/v1/userStats.ts | 51 ------------------- web/src/store/v2/user.ts | 51 +++++++++++++++++-- 12 files changed, 83 insertions(+), 93 deletions(-) delete mode 100644 web/src/store/v1/userStats.ts diff --git a/web/src/components/HomeSidebar/HomeSidebar.tsx b/web/src/components/HomeSidebar/HomeSidebar.tsx index 426dab74..ca0466c4 100644 --- a/web/src/components/HomeSidebar/HomeSidebar.tsx +++ b/web/src/components/HomeSidebar/HomeSidebar.tsx @@ -1,11 +1,12 @@ import { last } from "lodash-es"; import { Globe2Icon, HomeIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; import { matchPath, NavLink, useLocation } from "react-router-dom"; import useDebounce from "react-use/lib/useDebounce"; import SearchBar from "@/components/SearchBar"; import useCurrentUser from "@/hooks/useCurrentUser"; import { Routes } from "@/router"; -import { useMemoList, useUserStatsStore } from "@/store/v1"; +import { useMemoList } from "@/store/v1"; import { userStore } from "@/store/v2"; import { cn } from "@/utils"; import { useTranslate } from "@/utils/i18n"; @@ -25,12 +26,11 @@ interface Props { className?: string; } -const HomeSidebar = (props: Props) => { +const HomeSidebar = observer((props: Props) => { const t = useTranslate(); const location = useLocation(); const currentUser = useCurrentUser(); const memoList = useMemoList(); - const userStatsStore = useUserStatsStore(); const homeNavLink: NavLinkItem = { id: "header-home", @@ -55,13 +55,13 @@ const HomeSidebar = (props: Props) => { } if (matchPath("/u/:username", location.pathname) !== null) { const username = last(location.pathname.split("/")); - const user = await userStore.fetchUserByUsername(username || ""); + const user = await userStore.getOrFetchUserByUsername(username || ""); parent = user.name; } - await userStatsStore.listUserStats(parent); + await userStore.fetchUserStats(parent); }, 300, - [memoList.size(), userStatsStore.stateId, location.pathname], + [memoList.size(), userStore.state.statsStateId, location.pathname], ); return ( @@ -93,6 +93,6 @@ const HomeSidebar = (props: Props) => { ); -}; +}); export default HomeSidebar; diff --git a/web/src/components/HomeSidebar/TagsSection.tsx b/web/src/components/HomeSidebar/TagsSection.tsx index b7057b37..8f442eb7 100644 --- a/web/src/components/HomeSidebar/TagsSection.tsx +++ b/web/src/components/HomeSidebar/TagsSection.tsx @@ -1,9 +1,11 @@ import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy"; import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; import toast from "react-hot-toast"; import useLocalStorage from "react-use/lib/useLocalStorage"; import { memoServiceClient } from "@/grpcweb"; -import { useMemoFilterStore, useUserStatsStore, useUserStatsTags } from "@/store/v1"; +import { useMemoFilterStore } from "@/store/v1"; +import { userStore } from "@/store/v2"; import { cn } from "@/utils"; import { useTranslate } from "@/utils/i18n"; import showRenameTagDialog from "../RenameTagDialog"; @@ -14,12 +16,11 @@ interface Props { readonly?: boolean; } -const TagsSection = (props: Props) => { +const TagsSection = observer((props: Props) => { const t = useTranslate(); const memoFilterStore = useMemoFilterStore(); - const userStatsStore = useUserStatsStore(); const [treeMode, setTreeMode] = useLocalStorage("tag-view-as-tree", false); - const tags = Object.entries(useUserStatsTags()) + const tags = Object.entries(userStore.state.tagCount) .sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1]); @@ -42,7 +43,6 @@ const TagsSection = (props: Props) => { parent: "memos/-", tag: tag, }); - userStatsStore.setStateId(); toast.success(t("message.deleted-successfully")); } }; @@ -114,6 +114,6 @@ const TagsSection = (props: Props) => { )} ); -}; +}); export default TagsSection; diff --git a/web/src/components/MemoActionMenu.tsx b/web/src/components/MemoActionMenu.tsx index e04f70b8..b0694cee 100644 --- a/web/src/components/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu.tsx @@ -15,7 +15,8 @@ import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; import { markdownServiceClient } from "@/grpcweb"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { useMemoStore, useUserStatsStore } from "@/store/v1"; +import { useMemoStore } from "@/store/v1"; +import { userStore } from "@/store/v2"; import { State } from "@/types/proto/api/v1/common"; import { NodeType } from "@/types/proto/api/v1/markdown_service"; import { Memo } from "@/types/proto/api/v1/memo_service"; @@ -48,14 +49,13 @@ const MemoActionMenu = (props: Props) => { const location = useLocation(); const navigateTo = useNavigateTo(); const memoStore = useMemoStore(); - const userStatsStore = useUserStatsStore(); const isArchived = memo.state === State.ARCHIVED; const hasCompletedTaskList = checkHasCompletedTaskList(memo); const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); const memoUpdatedCallback = () => { // Refresh user stats. - userStatsStore.setStateId(); + userStore.setStatsStateId(); }; const handleTogglePinMemoBtnClick = async () => { diff --git a/web/src/components/MemoEditor/ActionButton/TagSelector.tsx b/web/src/components/MemoEditor/ActionButton/TagSelector.tsx index 1810dc60..7bcb7973 100644 --- a/web/src/components/MemoEditor/ActionButton/TagSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/TagSelector.tsx @@ -1,10 +1,11 @@ import { Dropdown, Menu, MenuButton } from "@mui/joy"; import { Button } from "@usememos/mui"; import { HashIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; import { useRef, useState } from "react"; import useClickAway from "react-use/lib/useClickAway"; import OverflowTip from "@/components/kit/OverflowTip"; -import { useUserStatsTags } from "@/store/v1"; +import { userStore } from "@/store/v2"; import { useTranslate } from "@/utils/i18n"; import { EditorRefActions } from "../Editor"; @@ -12,12 +13,12 @@ interface Props { editorRef: React.RefObject; } -const TagSelector = (props: Props) => { +const TagSelector = observer((props: Props) => { const t = useTranslate(); const { editorRef } = props; const [open, setOpen] = useState(false); const containerRef = useRef(null); - const tags = Object.entries(useUserStatsTags()) + const tags = Object.entries(userStore.state.tagCount) .sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1]) .map(([tag]) => tag); @@ -71,6 +72,6 @@ const TagSelector = (props: Props) => { ); -}; +}); export default TagSelector; diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index 703eddaf..26ca72f8 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -1,8 +1,9 @@ import Fuse from "fuse.js"; +import { observer } from "mobx-react-lite"; import { useEffect, useRef, useState } from "react"; import getCaretCoordinates from "textarea-caret"; import OverflowTip from "@/components/kit/OverflowTip"; -import { useUserStatsTags } from "@/store/v1"; +import { userStore } from "@/store/v2"; import { cn } from "@/utils"; import { EditorRefActions } from "."; @@ -13,12 +14,12 @@ type Props = { type Position = { left: number; top: number; height: number }; -const TagSuggestions = ({ editorRef, editorActions }: Props) => { +const TagSuggestions = observer(({ editorRef, editorActions }: Props) => { const [position, setPosition] = useState(null); const [selected, select] = useState(0); const selectedRef = useRef(selected); selectedRef.current = selected; - const tags = Object.entries(useUserStatsTags()) + const tags = Object.entries(userStore.state.tagCount) .sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1]) .map(([tag]) => tag); @@ -120,6 +121,6 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => { ))} ); -}; +}); export default TagSuggestions; diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx index 6c89f372..02d40e02 100644 --- a/web/src/components/MemoView.tsx +++ b/web/src/components/MemoView.tsx @@ -5,7 +5,7 @@ import { Link, useLocation } from "react-router-dom"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useCurrentUser from "@/hooks/useCurrentUser"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { useMemoStore, useUserStatsStore } from "@/store/v1"; +import { useMemoStore } from "@/store/v1"; import { userStore, workspaceStore } from "@/store/v2"; import { State } from "@/types/proto/api/v1/common"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_relation_service"; @@ -45,7 +45,6 @@ const MemoView: React.FC = (props: Props) => { const currentUser = useCurrentUser(); const user = useCurrentUser(); const memoStore = useMemoStore(); - const userStatsStore = useUserStatsStore(); const [showEditor, setShowEditor] = useState(false); const [creator, setCreator] = useState(userStore.getUserByName(memo.creator)); const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent); @@ -102,7 +101,7 @@ const MemoView: React.FC = (props: Props) => { const onEditorConfirm = () => { setShowEditor(false); - userStatsStore.setStateId(); + userStore.setStatsStateId(); }; const onPinIconClick = async () => { diff --git a/web/src/components/RenameTagDialog.tsx b/web/src/components/RenameTagDialog.tsx index f5811531..f46b50cc 100644 --- a/web/src/components/RenameTagDialog.tsx +++ b/web/src/components/RenameTagDialog.tsx @@ -5,7 +5,6 @@ import React, { useState } from "react"; import { toast } from "react-hot-toast"; import { memoServiceClient } from "@/grpcweb"; import useLoading from "@/hooks/useLoading"; -import { useUserStatsStore } from "@/store/v1"; import { useTranslate } from "@/utils/i18n"; import { generateDialog } from "./Dialog"; @@ -16,7 +15,6 @@ interface Props extends DialogProps { const RenameTagDialog: React.FC = (props: Props) => { const { tag, destroy } = props; const t = useTranslate(); - const userStatsStore = useUserStatsStore(); const [newName, setNewName] = useState(tag); const requestState = useLoading(false); @@ -41,7 +39,6 @@ const RenameTagDialog: React.FC = (props: Props) => { newTag: newName, }); toast.success("Rename tag successfully"); - userStatsStore.setStateId(); } catch (error: any) { console.error(error); toast.error(error.details); diff --git a/web/src/components/StatisticsView.tsx b/web/src/components/StatisticsView.tsx index 1e22ce52..d4c09c4d 100644 --- a/web/src/components/StatisticsView.tsx +++ b/web/src/components/StatisticsView.tsx @@ -2,21 +2,22 @@ import { Tooltip } from "@mui/joy"; import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { CheckCircleIcon, ChevronRightIcon, ChevronLeftIcon, Code2Icon, LinkIcon, ListTodoIcon } from "lucide-react"; +import { observer } from "mobx-react-lite"; import { useState } from "react"; import DatePicker from "react-datepicker"; import "react-datepicker/dist/react-datepicker.css"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import i18n from "@/i18n"; -import { useMemoFilterStore, useUserStatsStore } from "@/store/v1"; +import { useMemoFilterStore } from "@/store/v1"; +import { userStore } from "@/store/v2"; import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service"; import { cn } from "@/utils"; import { useTranslate } from "@/utils/i18n"; import ActivityCalendar from "./ActivityCalendar"; -const StatisticsView = () => { +const StatisticsView = observer(() => { const t = useTranslate(); const memoFilterStore = useMemoFilterStore(); - const userStatsStore = useUserStatsStore(); const [memoTypeStats, setMemoTypeStats] = useState(UserStats_MemoTypeStats.fromPartial({})); const [activityStats, setActivityStats] = useState>({}); const [selectedDate] = useState(new Date()); @@ -25,7 +26,7 @@ const StatisticsView = () => { useAsyncEffect(async () => { const memoTypeStats = UserStats_MemoTypeStats.fromPartial({}); const displayTimeList: Date[] = []; - for (const stats of Object.values(userStatsStore.userStatsByName)) { + for (const stats of Object.values(userStore.state.userStatsByName)) { displayTimeList.push(...stats.memoDisplayTimestamps); if (stats.memoTypeStats) { memoTypeStats.codeCount += stats.memoTypeStats.codeCount; @@ -36,7 +37,7 @@ const StatisticsView = () => { } setMemoTypeStats(memoTypeStats); setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")))); - }, [userStatsStore.userStatsByName, userStatsStore.stateId]); + }, [userStore.state.userStatsByName]); const onCalendarClick = (date: string) => { memoFilterStore.removeFilter((f) => f.factor === "displayTime"); @@ -135,6 +136,6 @@ const StatisticsView = () => { ); -}; +}); export default StatisticsView; diff --git a/web/src/pages/UserProfile.tsx b/web/src/pages/UserProfile.tsx index 3269a6ff..15db930c 100644 --- a/web/src/pages/UserProfile.tsx +++ b/web/src/pages/UserProfile.tsx @@ -31,7 +31,7 @@ const UserProfile = () => { } userStore - .fetchUserByUsername(username) + .getOrFetchUserByUsername(username) .then((user) => { setUser(user); loadingState.setFinish(); diff --git a/web/src/store/v1/index.ts b/web/src/store/v1/index.ts index abdb5a64..b1db34f0 100644 --- a/web/src/store/v1/index.ts +++ b/web/src/store/v1/index.ts @@ -2,4 +2,3 @@ export * from "./memo"; export * from "./resourceName"; export * from "./resource"; export * from "./memoFilter"; -export * from "./userStats"; diff --git a/web/src/store/v1/userStats.ts b/web/src/store/v1/userStats.ts deleted file mode 100644 index 486662d5..00000000 --- a/web/src/store/v1/userStats.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { uniqueId } from "lodash-es"; -import { create } from "zustand"; -import { combine } from "zustand/middleware"; -import { userServiceClient } from "@/grpcweb"; -import { UserStats } from "@/types/proto/api/v1/user_service"; - -interface State { - // stateId is used to identify the store instance state. - // It should be update when any state change. - stateId: string; - userStatsByName: Record; -} - -const getDefaultState = (): State => ({ - stateId: uniqueId(), - userStatsByName: {}, -}); - -export const useUserStatsStore = create( - combine(getDefaultState(), (set, get) => ({ - setState: (state: State) => set(state), - getState: () => get(), - listUserStats: async (user?: string) => { - const userStatsByName: Record = {}; - if (!user) { - const { userStats } = await userServiceClient.listAllUserStats({}); - for (const stats of userStats) { - userStatsByName[stats.name] = stats; - } - } else { - const userStats = await userServiceClient.getUserStats({ name: user }); - userStatsByName[user] = userStats; - } - set({ ...get(), userStatsByName }); - }, - setStateId: (id = uniqueId()) => { - set({ ...get(), stateId: id }); - }, - })), -); - -export const useUserStatsTags = () => { - const userStatsStore = useUserStatsStore(); - const tagAmounts: Record = {}; - for (const userStats of Object.values(userStatsStore.getState().userStatsByName)) { - for (const tag of Object.keys(userStats.tagCount)) { - tagAmounts[tag] = (tagAmounts[tag] || 0) + userStats.tagCount[tag]; - } - } - return tagAmounts; -}; diff --git a/web/src/store/v2/user.ts b/web/src/store/v2/user.ts index 77f2a70f..0eeae978 100644 --- a/web/src/store/v2/user.ts +++ b/web/src/store/v2/user.ts @@ -1,7 +1,8 @@ +import { uniqueId } from "lodash-es"; import { makeAutoObservable } from "mobx"; import { authServiceClient, inboxServiceClient, userServiceClient } from "@/grpcweb"; import { Inbox } from "@/types/proto/api/v1/inbox_service"; -import { Shortcut, User, UserSetting } from "@/types/proto/api/v1/user_service"; +import { Shortcut, User, UserSetting, UserStats } from "@/types/proto/api/v1/user_service"; import workspaceStore from "./workspace"; class LocalState { @@ -10,6 +11,20 @@ class LocalState { shortcuts: Shortcut[] = []; inboxes: Inbox[] = []; userMapByName: Record = {}; + userStatsByName: Record = {}; + + // The state id of user stats map. + statsStateId = uniqueId(); + + get tagCount() { + const tagCount: Record = {}; + for (const stats of Object.values(this.userStatsByName)) { + for (const tag of Object.keys(stats.tagCount)) { + tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag]; + } + } + return tagCount; + } constructor() { makeAutoObservable(this); @@ -40,13 +55,19 @@ const userStore = (() => { return user; }; - const fetchUserByUsername = async (username: string) => { + const getOrFetchUserByUsername = async (username: string) => { + const userMap = state.userMapByName; + for (const name in userMap) { + if (userMap[name].username === username) { + return userMap[name]; + } + } const user = await userServiceClient.getUserByUsername({ username, }); state.setPartial({ userMapByName: { - ...state.userMapByName, + ...userMap, [user.name]: user, }, }); @@ -138,10 +159,30 @@ const userStore = (() => { return updatedInbox; }; + const fetchUserStats = async (user?: string) => { + const userStatsByName: Record = {}; + if (!user) { + const { userStats } = await userServiceClient.listAllUserStats({}); + for (const stats of userStats) { + userStatsByName[stats.name] = stats; + } + } else { + const userStats = await userServiceClient.getUserStats({ name: user }); + userStatsByName[user] = userStats; + } + state.setPartial({ + userStatsByName, + }); + }; + + const setStatsStateId = (id = uniqueId()) => { + state.statsStateId = id; + }; + return { state, getOrFetchUserByName, - fetchUserByUsername, + getOrFetchUserByUsername, getUserByName, fetchUsers, updateUser, @@ -150,6 +191,8 @@ const userStore = (() => { fetchShortcuts, fetchInboxes, updateInbox, + fetchUserStats, + setStatsStateId, }; })();