This commit is contained in:
2026-04-25 16:36:34 +08:00
commit db90e7579b
1876 changed files with 189777 additions and 0 deletions

View File

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

View File

@@ -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<T> {
(previousState?: T): T;
}
export interface IFuncStorage {
(): Storage;
}
export interface Options<T> {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
defaultValue?: T | IFuncUpdater<T>;
}
export function createUseStorageState(getStorage: () => Storage | undefined) {
function useStorageState<T>(key: string, options?: Options<T>) {
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<T>(() => getStoredValue());
useUpdateEffect(() => {
setState(getStoredValue());
}, [key]);
const updateState = (value: T | IFuncUpdater<T>) => {
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;
}

View File

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
import { fetchAvailableServices } from '../../model/common';
import { useAsyncFn } from '../useAsyncFn';
import { useMemoizedFn } from '../useMemoizedFn';
/**
* 用于监测服务是否可用的hooks
*/
export function useAvailableServices() {
const [{ loading, value: availableServices }, fetch] = useAsyncFn(() =>
fetchAvailableServices()
);
useEffect(() => {
fetch();
}, []);
const refetch = useMemoizedFn(async () => {
fetchAvailableServices.clearCache();
fetch();
});
return { loading, availableServices, refetch };
}

View File

@@ -0,0 +1,23 @@
import { sharedEvent, useSharedEventHandler } from '../../event';
import { useUserNotifyMute } from './useUserSettings';
/**
* 消息通知翻译
* 检查用户设置,接受已读消息并发送未静音消息
*
* 接收到消息事件{receiveMessage} -> 检查是否被静音 -> 没有静音,发送{receiveUnmutedMessage}事件
* -> 静音, 不做任何处理
*/
export function useMessageNotifyEventFilter() {
const { checkIsMuted } = useUserNotifyMute();
useSharedEventHandler('receiveMessage', (payload) => {
if (!payload) {
return;
}
if (!checkIsMuted(payload.converseId, payload.groupId)) {
sharedEvent.emit('receiveUnmutedMessage', payload);
}
});
}

View File

@@ -0,0 +1,19 @@
import { getCachedUserInfo } from '../../cache/cache';
import type { UserBaseInfo } from '../../model/user';
import { useAsync } from '../useAsync';
/**
* 用户信息
*/
export function useCachedUserInfo(
userId: string,
refetch = false
): UserBaseInfo | Record<string, never> {
const { value: userInfo = {} } = useAsync(async () => {
const users = getCachedUserInfo(userId, refetch);
return users;
}, [userId, refetch]);
return userInfo ?? {};
}

View File

@@ -0,0 +1,16 @@
import { getCachedUserInfo } from '../../cache/cache';
import type { UserBaseInfo } from '../../model/user';
import { useAsync } from '../useAsync';
/**
* 用户信息列表
*/
export function useUserInfoList(userIds: string[] = []): UserBaseInfo[] {
const { value: userInfoList = [] } = useAsync(async () => {
const users = await Promise.all(userIds.map((id) => getCachedUserInfo(id)));
return users;
}, [userIds.join(',')]);
return userInfoList;
}

View File

@@ -0,0 +1,110 @@
import { CacheKey } from '../../cache/cache';
import { useQuery, useQueryClient } from '../../cache/useCache';
import { sharedEvent } from '../../event';
import {
getUserSettings,
setUserSettings,
UserSettings,
} from '../../model/user';
import { useAsyncRequest } from '../useAsyncRequest';
import { useMemoizedFn } from '../useMemoizedFn';
import _without from 'lodash/without';
/**
* 用户设置hooks
*/
export function useUserSettings() {
const client = useQueryClient();
const { data: settings, isLoading } = useQuery(
[CacheKey.userSettings],
() => getUserSettings(),
{
staleTime: 10 * 60 * 1000, // 缓存10分钟
}
);
const [{ loading: saveLoading }, setSettings] = useAsyncRequest(
async (_settings: UserSettings) => {
client.setQueryData([CacheKey.userSettings], () => ({
...settings,
..._settings,
})); // 让配置能够立即生效, 防止依赖配置的行为出现跳变(如GroupNav)
const newSettings = await setUserSettings(_settings);
client.setQueryData([CacheKey.userSettings], () => newSettings);
sharedEvent.emit('userSettingsUpdate', newSettings);
},
[client]
);
return {
settings: settings ?? {},
setSettings,
loading: isLoading || saveLoading,
};
}
/**
* 单个用户设置
*/
export function useSingleUserSetting<K extends keyof UserSettings>(
name: K,
defaultValue?: UserSettings[K]
) {
const { settings, setSettings, loading } = useUserSettings();
return {
value: settings[name] ?? defaultValue,
setValue: async (newVal: UserSettings[K]) =>
setSettings({
[name]: newVal,
}),
loading,
};
}
/**
* 用户消息通知免打扰设置
*/
export function useUserNotifyMute() {
const { value: list = [], setValue: setList } = useSingleUserSetting(
'messageNotificationMuteList',
[]
);
const mute = useMemoizedFn((converseOrGroupId: string) => {
setList([...list, converseOrGroupId]);
});
const unmute = useMemoizedFn((converseOrGroupId: string) => {
setList(_without(list, converseOrGroupId));
});
const toggleMute = useMemoizedFn((converseOrGroupId) => {
if (list.includes(converseOrGroupId)) {
unmute(converseOrGroupId);
} else {
mute(converseOrGroupId);
}
});
/**
* 检查是否被静音
*/
const checkIsMuted = useMemoizedFn((panelId: string, groupId?: string) => {
if (groupId) {
return list.includes(panelId) || list.includes(groupId);
}
return list.includes(panelId);
});
return {
mutedList: list,
mute,
unmute,
toggleMute,
checkIsMuted,
};
}

View File

@@ -0,0 +1,10 @@
import { useUserInfoList } from './useUserInfoList';
/**
* 用户名列表
*/
export function useUsernames(userIds: string[]): string[] {
const userInfoList = useUserInfoList(userIds);
return userInfoList.map((info) => info.nickname);
}

View File

@@ -0,0 +1,16 @@
import { useStorage } from '../manager/storage';
const alphaModeKey = 'alphaMode';
/**
* 是否为 alpha 模式
* 在 alpha 模式下可以看到一些可以被公开但是还在测试中的功能
*/
export function useAlphaMode() {
const [isAlphaMode, { save: setAlphaMode }] = useStorage<boolean>(
alphaModeKey,
false
);
return { isAlphaMode, setAlphaMode };
}

View File

@@ -0,0 +1,29 @@
import { DependencyList, useEffect, useRef } from 'react';
import type { FunctionReturningPromise } from '../types';
import { useAsyncFn } from './useAsyncFn';
// Reference: https://github.com/streamich/react-use/blob/master/src/useAsync.ts
export function useAsync<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = []
) {
const [state, callback] = useAsyncFn(fn, deps, {
loading: true,
});
const lockRef = useRef(false);
useEffect(() => {
if (lockRef.current === true) {
return;
}
if (deps.length === 0) {
lockRef.current = true;
}
callback();
}, [callback]);
return state;
}

View File

@@ -0,0 +1,69 @@
import { DependencyList, useCallback, useRef, useState } from 'react';
import type { FunctionReturningPromise, PromiseType } from '../types';
import { useMountedState } from './useMountedState';
// Reference: https://github.com/streamich/react-use/blob/master/src/useAsyncFn.ts
export type AsyncState<T> =
| {
loading: boolean;
error?: undefined;
value?: undefined;
}
| {
loading: true;
error?: Error | undefined;
value?: T;
}
| {
loading: false;
error: Error;
value?: undefined;
}
| {
loading: false;
error?: undefined;
value: T;
};
type StateFromFunctionReturningPromise<T extends FunctionReturningPromise> =
AsyncState<PromiseType<ReturnType<T>>>;
export type AsyncFnReturn<
T extends FunctionReturningPromise = FunctionReturningPromise
> = [StateFromFunctionReturningPromise<T>, T];
export function useAsyncFn<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = [],
initialState: StateFromFunctionReturningPromise<T> = { loading: false }
): AsyncFnReturn<T> {
const lastCallId = useRef(0);
const isMounted = useMountedState();
const [state, set] =
useState<StateFromFunctionReturningPromise<T>>(initialState);
const callback = useCallback((...args: Parameters<T>): ReturnType<T> => {
const callId = ++lastCallId.current;
set((prevState) => ({ ...prevState, loading: true }));
return fn(...args).then(
(value) => {
isMounted() &&
callId === lastCallId.current &&
set({ value, loading: false });
return value;
},
(error) => {
isMounted() &&
callId === lastCallId.current &&
set({ error, loading: false });
return error;
}
) as ReturnType<T>;
}, deps);
return [state, callback as unknown as T];
}

View File

@@ -0,0 +1,25 @@
import { DependencyList, useCallback, useEffect } from 'react';
import type { FunctionReturningPromise } from '../types';
import { useAsyncFn } from './useAsyncFn';
export function useAsyncRefresh<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = []
) {
const [state, callback] = useAsyncFn(fn, deps, {
loading: true,
});
useEffect(() => {
callback();
}, [callback]);
const refresh = useCallback(() => {
return callback();
}, [callback]);
return {
...state,
refresh,
};
}

View File

@@ -0,0 +1,21 @@
import type { DependencyList } from 'react';
import { showErrorToasts } from '../manager/ui';
import type { FunctionReturningPromise } from '../types';
import { useAsyncFn } from './useAsyncFn';
export function useAsyncRequest<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = []
) {
const [{ loading, value }, call] = useAsyncFn(async (...args: any[]) => {
try {
return await fn(...args);
} catch (err) {
// showErrorToasts(isDevelopment ? err : t('系统忙, 请稍后再试'));
showErrorToasts(err); // 暂时放开所有错误抛出正确的做法应该是仅对于内置代码相关的逻辑显示placeholder报错
console.error('[useAsyncRequest] error:', err);
}
}, deps);
return [{ loading, value }, call as T] as const;
}

View File

@@ -0,0 +1,27 @@
import { DependencyList, useLayoutEffect, useRef } from 'react';
import { useEvent } from './useEvent';
/**
* Call once on data ready(validator return true)
*/
export function useDataReady(
validator: () => boolean,
cb: () => void,
deps?: DependencyList
) {
const isReadyRef = useRef(false);
const _validator = useEvent(validator);
const _callback = useEvent(cb);
useLayoutEffect(() => {
if (isReadyRef.current) {
return;
}
if (_validator() === true) {
_callback();
isReadyRef.current = true;
}
}, deps);
}

View File

@@ -0,0 +1,17 @@
import { DependencyList, useEffect } from 'react';
import { useTimeoutFn } from './useTimeoutFn';
export type UseDebounceReturn = [() => boolean | null, () => void];
export function useDebounce(
// eslint-disable-next-line @typescript-eslint/ban-types
fn: Function,
ms = 0,
deps: DependencyList = []
): UseDebounceReturn {
const [isReady, cancel, reset] = useTimeoutFn(fn, ms);
useEffect(reset, deps);
return [isReady, cancel];
}

View File

@@ -0,0 +1,15 @@
import { useCallback, useLayoutEffect, useState } from 'react';
export function useEditValue<T>(value: T, onChange: (val: T) => void) {
const [inner, setInner] = useState(value);
useLayoutEffect(() => {
setInner(value);
}, [value]);
const onSave = useCallback(() => {
onChange(inner);
}, [inner, onChange]);
return [inner, setInner, onSave] as const;
}

View File

@@ -0,0 +1,7 @@
import { EffectCallback, useEffect } from 'react';
// Reference: https://github.com/streamich/react-use/blob/master/src/useEffectOnce.ts
export const useEffectOnce = (effect: EffectCallback) => {
useEffect(effect, []);
};

View File

@@ -0,0 +1,3 @@
import { useMemoizedFn } from './useMemoizedFn';
export const useEvent = useMemoizedFn;

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef } from 'react';
import { useUpdateRef } from './useUpdateRef';
export function useInterval(
fn: () => void,
delay: number | undefined,
options?: {
immediate?: boolean;
}
) {
const immediate = options?.immediate;
const fnRef = useUpdateRef(fn);
const timerRef = useRef<number>();
useEffect(() => {
if (typeof delay !== 'number' || delay < 0) {
return;
}
if (immediate) {
fnRef.current();
}
timerRef.current = window.setInterval(() => {
fnRef.current();
}, delay);
return () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
}
};
}, [delay]);
const clear = useCallback(() => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
}
}, []);
return clear;
}

View File

@@ -0,0 +1,17 @@
import { useLayoutEffect, useState } from 'react';
import { useEvent } from './useEvent';
export function useLazyValue<T>(value: T, onChange: (val: T) => void) {
const [inner, setInner] = useState(value);
useLayoutEffect(() => {
setInner(value);
}, [value]);
const handleChange = useEvent((val: T) => {
setInner(val);
onChange(val);
});
return [inner, handleChange] as const;
}

View File

@@ -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<T extends Noop> = (
this: ThisParameterType<T>,
...args: Parameters<T>
) => ReturnType<T>;
export function useMemoizedFn<T extends Noop>(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<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args);
};
}
return memoizedFn.current as T;
}

View File

@@ -0,0 +1,18 @@
import { useCallback, useEffect, useRef } from 'react';
// Reference: https://github.com/streamich/react-use/blob/master/src/useMountedState.ts
export function useMountedState(): () => boolean {
const mountedRef = useRef<boolean>(false);
const get = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return get;
}

View File

@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export function usePrevious<T>(state: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = state;
});
return ref.current;
}

View File

@@ -0,0 +1,26 @@
import { Dispatch, SetStateAction, useCallback, useRef, useState } from 'react';
import { useUnmount } from './useUnmount';
// Reference: https://github.com/streamich/react-use/blob/master/src/useRafState.ts
export const useRafState = <S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>] => {
const frame = useRef(0);
const [state, setState] = useState(initialState);
const setRafState = useCallback((value: S | ((prevState: S) => S)) => {
cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
setState(value);
});
}, []);
useUnmount(() => {
cancelAnimationFrame(frame.current);
});
return [state, setRafState];
};

View File

@@ -0,0 +1,57 @@
import { useMemo, useState } from 'react';
import { useFriendNicknameMap } from '../redux/hooks/useFriendNickname';
import type { UserBaseInfo } from 'tailchat-types';
export interface UseSearchOptions<T> {
dataSource: T[];
filterFn: (item: T, searchText: string) => boolean;
}
export function useSearch<T>(options: UseSearchOptions<T>) {
const { dataSource, filterFn } = options;
const [searchText, setSearchText] = useState('');
const isSearching = searchText !== '';
const searchResult = useMemo(() => {
return dataSource.filter((item) => filterFn(item, searchText));
}, [dataSource, searchText]);
return {
searchText,
setSearchText,
isSearching,
searchResult,
};
}
/**
* 用于搜索用户的封装函数
*/
export function useUserSearch(userInfos: UserBaseInfo[]) {
const friendNicknameMap = useFriendNicknameMap();
const validUserInfos = useMemo(() => userInfos.filter(Boolean), [userInfos]);
const { searchText, setSearchText, isSearching, searchResult } = useSearch({
dataSource: validUserInfos,
filterFn: (item, searchText) => {
if (friendNicknameMap[item._id]) {
if (friendNicknameMap[item._id].includes(searchText)) {
return true;
}
}
if (item.nickname.includes(searchText)) {
return true;
}
return false;
},
});
return {
searchText,
setSearchText,
isSearching,
searchResult,
};
}

View File

@@ -0,0 +1,17 @@
import { useLayoutEffect, useState } from 'react';
import { shallowEqual } from 'react-redux';
/**
* 对输入对象增加一层浅比较, 如果对象浅比较结果一致则返回原对象(防止更新)
*/
export function useShallowObject<T>(object: T): T {
const [state, setState] = useState<T>(object);
useLayoutEffect(() => {
if (!shallowEqual(state, object)) {
setState(object);
}
}, [object]);
return state;
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef } from 'react';
export type UseTimeoutFnReturn = [() => boolean | null, () => void, () => void];
export function useTimeoutFn(
// eslint-disable-next-line @typescript-eslint/ban-types
fn: Function,
ms = 0
): UseTimeoutFnReturn {
const ready = useRef<boolean | null>(false);
const timeout = useRef<ReturnType<typeof setTimeout>>();
const callback = useRef(fn);
const isReady = useCallback(() => ready.current, []);
const set = useCallback(() => {
ready.current = false;
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
ready.current = true;
callback.current();
}, ms);
}, [ms]);
const clear = useCallback(() => {
ready.current = null;
timeout.current && clearTimeout(timeout.current);
}, []);
// update ref when function changes
useEffect(() => {
callback.current = fn;
}, [fn]);
// set on mount, clear on unmount
useEffect(() => {
set();
return clear;
}, [ms]);
return [isReady, clear, set];
}

View File

@@ -0,0 +1,13 @@
import { useRef } from 'react';
import { useEffectOnce } from './useEffectOnce';
// Reference: https://github.com/streamich/react-use/blob/master/src/useUnmount.ts
export const useUnmount = (fn: () => any): void => {
const fnRef = useRef(fn);
// update the ref each render so if it change the newest callback will be invoked
fnRef.current = fn;
useEffectOnce(() => () => fnRef.current());
};

View File

@@ -0,0 +1,4 @@
import { useEffect } from 'react';
import { createUpdateEffect } from './factory/createUpdateEffect';
export const useUpdateEffect = createUpdateEffect(useEffect);

View File

@@ -0,0 +1,8 @@
import { useRef, MutableRefObject } from 'react';
export function useUpdateRef<T>(state: T): MutableRefObject<T> {
const ref = useRef<T>(state);
ref.current = state;
return ref;
}

View File

@@ -0,0 +1,12 @@
import { DependencyList, useLayoutEffect } from 'react';
import { useMemoizedFn } from './useMemoizedFn';
/**
* 监听变更并触发回调
*/
export function useWatch(deps: DependencyList, cb: () => void) {
const memoizedFn = useMemoizedFn(cb);
useLayoutEffect(() => {
memoizedFn();
}, deps);
}

View File

@@ -0,0 +1,103 @@
/**
* 检查组件变动情况(是什么导致了组件刷新)
* Fork from: https://github.com/devhubapp/devhub/blob/master/packages/components/src/hooks/use-why-did-you-update.ts
*/
import { parse, stringify } from 'flatted';
import _get from 'lodash/get';
import _isEqual from 'lodash/isEqual';
import { useEffect, useRef } from 'react';
import { isDevelopment } from '../utils/environment';
interface UseWhyDidYouUpdateCallback<T> {
onChangeFound?: (data: {
changesObj: Record<
string,
{
from: T;
to: T;
isDeepEqual: boolean;
changedKeys?: string[];
}
>;
}) => void;
onNoChangeFound?: () => void;
}
/**
* Quickly see which prop changed
* and caused a re-render by adding a single line to the component.
*
* USAGE:
* function MyComponent(props) {
* useWhyDidYouUpdate('MyComponent', props)
*
* return <div ... />
* }
*
* OUTPUT:
* [why-did-you-update] MyComponent { myProp: { from 'oldvalue', to: 'newvalue' } }
*
* SHARE:
* This tip on Twitter: https://twitter.com/brunolemos/status/1090377532845801473
* Also follow @brunolemos: https://twitter.com/brunolemos
*/
export function useWhyDidYouUpdate<T>(
name: string,
props: Record<string, T>,
{ onChangeFound, onNoChangeFound }: UseWhyDidYouUpdateCallback<T> = {}
) {
const latestProps = useRef(props);
useEffect(() => {
if (!isDevelopment) return;
const allKeys = Object.keys({ ...latestProps.current, ...props });
const changesObj: Record<
string,
{
from: T;
to: T;
isDeepEqual: boolean;
changedKeys?: string[];
}
> = {};
allKeys.forEach((key) => {
if (latestProps.current[key] !== props[key]) {
changesObj[key] = {
from: latestProps.current[key],
to: props[key],
changedKeys:
props[key] && typeof props[key] === 'object'
? Object.keys(latestProps.current[key] as object)
.map((k) =>
_get(latestProps.current, [key, k]) ===
_get(props, [key, k])
? ''
: k
)
.filter(Boolean)
: undefined,
isDeepEqual: _isEqual(latestProps.current[key], props[key]),
};
}
});
if (Object.keys(changesObj).length) {
if (onChangeFound) {
onChangeFound({ changesObj });
} else {
// eslint-disable-next-line no-console
console.log('[why-did-you-update]', name, {
changes: parse(stringify(changesObj)),
props: { from: latestProps.current, to: props },
});
}
} else if (onNoChangeFound) {
onNoChangeFound();
}
latestProps.current = props;
});
}