From 5e00ed8e259700741b7abd896c313953425486d7 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Mon, 6 Jun 2022 21:57:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=87=E6=8D=A2=E9=A2=91=E9=81=93?= =?UTF-8?q?=E6=97=B6=E8=AE=B0=E5=BD=95=E6=9C=80=E5=90=8E=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=88=B0=E7=9A=84=E9=A2=91=E9=81=93=E3=80=82?= =?UTF-8?q?=E5=87=8F=E5=B0=91=E6=97=A0=E6=84=8F=E4=B9=89=E7=9A=84=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/hooks/factory/createUpdateEffect.ts | 26 ++++++ shared/hooks/factory/createUseStorageState.ts | 85 +++++++++++++++++++ shared/hooks/useMemoizedFn.ts | 36 ++++++++ shared/hooks/useUpdateEffect.ts | 4 + shared/index.tsx | 2 + web/src/components/Panel/group/Wrapper.tsx | 21 ++++- web/src/hooks/useLocalStorage.ts | 3 + web/src/hooks/useSessionStorageState.ts | 5 ++ web/src/hooks/useUserPreference.ts | 30 +++++++ web/src/plugin/manager.ts | 1 - .../Main/Content/Group/PanelRedirect.tsx | 25 +++++- 11 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 shared/hooks/factory/createUpdateEffect.ts create mode 100644 shared/hooks/factory/createUseStorageState.ts create mode 100644 shared/hooks/useMemoizedFn.ts create mode 100644 shared/hooks/useUpdateEffect.ts create mode 100644 web/src/hooks/useLocalStorage.ts create mode 100644 web/src/hooks/useSessionStorageState.ts create mode 100644 web/src/hooks/useUserPreference.ts diff --git a/shared/hooks/factory/createUpdateEffect.ts b/shared/hooks/factory/createUpdateEffect.ts new file mode 100644 index 00000000..ce20f720 --- /dev/null +++ b/shared/hooks/factory/createUpdateEffect.ts @@ -0,0 +1,26 @@ +import { useRef } from 'react'; +import type { useEffect, useLayoutEffect } from 'react'; + +// Reference: https://github.com/alibaba/hooks/blob/master/packages/hooks/src/createUpdateEffect/index.ts + +type EffectHookType = typeof useEffect | typeof useLayoutEffect; + +export const createUpdateEffect: (hook: EffectHookType) => EffectHookType = + (hook) => (effect, deps) => { + const isMounted = useRef(false); + + // for react-refresh + hook(() => { + return () => { + isMounted.current = false; + }; + }, []); + + hook(() => { + if (!isMounted.current) { + isMounted.current = true; + } else { + return effect(); + } + }, deps); + }; diff --git a/shared/hooks/factory/createUseStorageState.ts b/shared/hooks/factory/createUseStorageState.ts new file mode 100644 index 00000000..718f93a7 --- /dev/null +++ b/shared/hooks/factory/createUseStorageState.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-empty */ +import { useState } from 'react'; +import { useMemoizedFn } from '../useMemoizedFn'; +import { useUpdateEffect } from '../useUpdateEffect'; +import _isFunction from 'lodash/isFunction'; +import _isUndefined from 'lodash/isUndefined'; + +export interface IFuncUpdater { + (previousState?: T): T; +} +export interface IFuncStorage { + (): Storage; +} + +export interface Options { + serializer?: (value: T) => string; + deserializer?: (value: string) => T; + defaultValue?: T | IFuncUpdater; +} + +export function createUseStorageState(getStorage: () => Storage | undefined) { + function useStorageState(key: string, options?: Options) { + let storage: Storage | undefined; + + // https://github.com/alibaba/hooks/issues/800 + try { + storage = getStorage(); + } catch (err) { + console.error(err); + } + + const serializer = (value: T) => { + if (options?.serializer) { + return options?.serializer(value); + } + return JSON.stringify(value); + }; + + const deserializer = (value: string) => { + if (options?.deserializer) { + return options?.deserializer(value); + } + return JSON.parse(value); + }; + + function getStoredValue() { + try { + const raw = storage?.getItem(key); + if (raw) { + return deserializer(raw); + } + } catch (e) { + console.error(e); + } + if (_isFunction(options?.defaultValue)) { + return options?.defaultValue(); + } + return options?.defaultValue; + } + + const [state, setState] = useState(() => getStoredValue()); + + useUpdateEffect(() => { + setState(getStoredValue()); + }, [key]); + + const updateState = (value: T | IFuncUpdater) => { + const currentState = _isFunction(value) ? value(state) : value; + setState(currentState); + + if (_isUndefined(currentState)) { + storage?.removeItem(key); + } else { + try { + storage?.setItem(key, serializer(currentState)); + } catch (e) { + console.error(e); + } + } + }; + + return [state, useMemoizedFn(updateState)] as const; + } + return useStorageState; +} diff --git a/shared/hooks/useMemoizedFn.ts b/shared/hooks/useMemoizedFn.ts new file mode 100644 index 00000000..37d0239b --- /dev/null +++ b/shared/hooks/useMemoizedFn.ts @@ -0,0 +1,36 @@ +import { useMemo, useRef } from 'react'; +import _isFunction from 'lodash/isFunction'; + +// From https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useMemoizedFn/index.ts + +type Noop = (this: any, ...args: any[]) => any; + +type PickFunction = ( + this: ThisParameterType, + ...args: Parameters +) => ReturnType; + +export function useMemoizedFn(fn: T) { + if (process.env.NODE_ENV === 'development') { + if (!_isFunction(fn)) { + console.error( + `useMemoizedFn expected parameter is a function, got ${typeof fn}` + ); + } + } + + const fnRef = useRef(fn); + + // why not write `fnRef.current = fn`? + // https://github.com/alibaba/hooks/issues/728 + fnRef.current = useMemo(() => fn, [fn]); + + const memoizedFn = useRef>(); + if (!memoizedFn.current) { + memoizedFn.current = function (this, ...args) { + return fnRef.current.apply(this, args); + }; + } + + return memoizedFn.current as T; +} diff --git a/shared/hooks/useUpdateEffect.ts b/shared/hooks/useUpdateEffect.ts new file mode 100644 index 00000000..f8fc0207 --- /dev/null +++ b/shared/hooks/useUpdateEffect.ts @@ -0,0 +1,4 @@ +import { useEffect } from 'react'; +import { createUpdateEffect } from './factory/createUpdateEffect'; + +export const useUpdateEffect = createUpdateEffect(useEffect); diff --git a/shared/index.tsx b/shared/index.tsx index e7d693b8..5bfdcfd3 100644 --- a/shared/index.tsx +++ b/shared/index.tsx @@ -47,6 +47,7 @@ export { Trans } from './i18n/Trans'; export { useLanguage } from './i18n/language'; // hooks +export { createUseStorageState } from './hooks/factory/createUseStorageState'; export { useAvailableServices } from './hooks/model/useAvailableServices'; export { useUsernames } from './hooks/model/useUsernames'; export { @@ -58,6 +59,7 @@ export { useAsyncFn } from './hooks/useAsyncFn'; export { useAsyncRefresh } from './hooks/useAsyncRefresh'; export { useAsyncRequest } from './hooks/useAsyncRequest'; export { useDebounce } from './hooks/useDebounce'; +export { useMemoizedFn } from './hooks/useMemoizedFn'; export { useMountedState } from './hooks/useMountedState'; export { usePrevious } from './hooks/usePrevious'; export { useRafState } from './hooks/useRafState'; diff --git a/web/src/components/Panel/group/Wrapper.tsx b/web/src/components/Panel/group/Wrapper.tsx index 28536e1e..030ccc0b 100644 --- a/web/src/components/Panel/group/Wrapper.tsx +++ b/web/src/components/Panel/group/Wrapper.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { t, useGroupPanelInfo } from 'tailchat-shared'; import _isNil from 'lodash/isNil'; import { MembersPanel } from './MembersPanel'; @@ -7,10 +7,26 @@ import { usePanelWindow } from '@/hooks/usePanelWindow'; import { OpenedPanelTip } from '@/components/OpenedPanelTip'; import { IconBtn } from '@/components/IconBtn'; import { - DMPluginPanelActionProps, GroupPluginPanelActionProps, pluginPanelActions, } from '@/plugin/common'; +import { useUserSessionPreference } from '@/hooks/useUserPreference'; + +/** + * 记录下最后访问的面板id + */ +function useRecordGroupPanel(groupId: string, panelId: string) { + const [lastVisitPanel, setLastVisitPanel] = useUserSessionPreference( + 'groupLastVisitPanel' + ); + + useEffect(() => { + setLastVisitPanel({ + ...lastVisitPanel, + [groupId]: panelId, + }); + }, [groupId, panelId]); +} /** * 群组面板通用包装器 @@ -27,6 +43,7 @@ interface GroupPanelWrapperProps { export const GroupPanelWrapper: React.FC = React.memo( (props) => { const panelInfo = useGroupPanelInfo(props.groupId, props.panelId); + useRecordGroupPanel(props.groupId, props.panelId); if (_isNil(panelInfo)) { return null; diff --git a/web/src/hooks/useLocalStorage.ts b/web/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000..75d4c3ea --- /dev/null +++ b/web/src/hooks/useLocalStorage.ts @@ -0,0 +1,3 @@ +import { createUseStorageState } from 'tailchat-shared'; + +export const useLocalStorageState = createUseStorageState(() => localStorage); diff --git a/web/src/hooks/useSessionStorageState.ts b/web/src/hooks/useSessionStorageState.ts new file mode 100644 index 00000000..58d1185e --- /dev/null +++ b/web/src/hooks/useSessionStorageState.ts @@ -0,0 +1,5 @@ +import { createUseStorageState } from 'tailchat-shared'; + +export const useSessionStorageState = createUseStorageState( + () => sessionStorage +); diff --git a/web/src/hooks/useUserPreference.ts b/web/src/hooks/useUserPreference.ts new file mode 100644 index 00000000..6b256172 --- /dev/null +++ b/web/src/hooks/useUserPreference.ts @@ -0,0 +1,30 @@ +import { useSessionStorageState } from './useSessionStorageState'; +import { useMemoizedFn } from 'tailchat-shared'; + +interface UserSessionPerference { + /** + * 用户最后访问群组的面板id + * 用于切换群组时回到最后一个 + */ + groupLastVisitPanel?: Record; +} + +/** + * 用户偏好 + * 用于在本地缓存一些不是那么重要的数据 + */ +export function useUserSessionPreference( + scope: T +): [UserSessionPerference[T], (value: UserSessionPerference[T]) => void] { + const [preference = {}, setPreference] = + useSessionStorageState('sessionPreference'); + const value = preference[scope]; + const setValue = useMemoizedFn((value: UserSessionPerference[T]) => { + setPreference({ + ...preference, + [scope]: value, + }); + }); + + return [value, setValue]; +} diff --git a/web/src/plugin/manager.ts b/web/src/plugin/manager.ts index 8b9b7794..2a8e56d4 100644 --- a/web/src/plugin/manager.ts +++ b/web/src/plugin/manager.ts @@ -2,7 +2,6 @@ import { getCachedRegistryPlugins, getStorage, PluginManifest, - t, } from 'tailchat-shared'; import { initMiniStar, loadSinglePlugin } from 'mini-star'; import _once from 'lodash/once'; diff --git a/web/src/routes/Main/Content/Group/PanelRedirect.tsx b/web/src/routes/Main/Content/Group/PanelRedirect.tsx index 4ef9fcd9..0c8034f1 100644 --- a/web/src/routes/Main/Content/Group/PanelRedirect.tsx +++ b/web/src/routes/Main/Content/Group/PanelRedirect.tsx @@ -1,21 +1,42 @@ import React, { useEffect } from 'react'; import { useHistory, useParams } from 'react-router'; -import { GroupPanelType, useGroupInfo } from 'tailchat-shared'; +import { GroupPanelType, useGroupInfo, useUpdateRef } from 'tailchat-shared'; import _isNil from 'lodash/isNil'; +import { useUserSessionPreference } from '@/hooks/useUserPreference'; export const GroupPanelRedirect: React.FC = React.memo(() => { const { groupId } = useParams<{ groupId: string; }>(); const history = useHistory(); + const [lastVisitPanel] = useUserSessionPreference('groupLastVisitPanel'); + const lastVisitPanelRef = useUpdateRef(lastVisitPanel); const groupInfo = useGroupInfo(groupId); useEffect(() => { + if (!groupInfo) { + return; + } + if (!Array.isArray(groupInfo?.panels) || groupInfo?.panels.length === 0) { return; } - const firstAvailablePanel = groupInfo?.panels.find( + const lastVisitPanelId = lastVisitPanelRef.current?.[groupInfo._id]; // 用户上一次访问 + const panels = groupInfo.panels; + + /** + * 首先找之前有没有打开过面板的记录 + * 如果有则打开面板 + * 否则则找到第一个非group的面板 + */ + const panelExist = panels.some((p) => p.id === lastVisitPanelId); + if (panelExist) { + history.replace(`/main/group/${groupId}/${lastVisitPanelId}`); + return; + } + + const firstAvailablePanel = panels.find( (panel) => panel.type !== GroupPanelType.GROUP ); if (!_isNil(firstAvailablePanel)) {