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,41 @@
import { useAppDispatch, useAppSelector } from './useAppSelector';
import { chatActions } from '../slices';
import { useEvent } from '../../hooks/useEvent';
import { getCachedAckInfo } from '../../cache/cache';
export function useAckInfoChecker() {
const ack = useAppSelector((state) => state.chat.ack);
const lastMessageMap = useAppSelector((state) => state.chat.lastMessageMap);
const dispatch = useAppDispatch();
const ensureAckInfo = useEvent((converseId: string) => {
if (
ack[converseId] === undefined ||
lastMessageMap[converseId] === undefined
) {
getCachedAckInfo(converseId).then((info) => {
if (info.ack?.lastMessageId) {
dispatch(
chatActions.setConverseAck({
converseId,
lastMessageId: info.ack.lastMessageId,
})
);
}
if (info.lastMessage?.lastMessageId) {
dispatch(
chatActions.setLastMessageMap([
{
converseId,
lastMessageId: info.lastMessage.lastMessageId,
},
])
);
}
});
}
});
return { ensureAckInfo };
}

View File

@@ -0,0 +1,15 @@
import type { AppState } from '../slices';
import { useSelector, useDispatch, useStore } from 'react-redux';
export function useAppSelector<T>(
selector: (state: AppState) => T,
equalityFn?: (left: T, right: T) => boolean
) {
return useSelector<AppState, T>(selector, equalityFn);
}
export const useAppDispatch = useDispatch;
export function useAppStore<AppState>() {
return useStore<AppState>();
}

View File

@@ -0,0 +1,31 @@
import { useMemo } from 'react';
import { ChatConverseType } from '../../model/converse';
import type { ChatConverseState } from '../slices/chat';
import { useAppSelector } from './useAppSelector';
/**
* 获取私信会话列表
* 并补充一些信息
*/
export function useDMConverseList(): ChatConverseState[] {
const converses = useAppSelector((state) => state.chat.converses);
const lastMessageMap = useAppSelector((state) => state.chat.lastMessageMap);
const filteredConverse = useMemo(
() =>
Object.entries(converses)
.filter(([, info]) =>
[ChatConverseType.DM, ChatConverseType.Multi].includes(info.type)
)
.map(([, info]) => info),
[converses]
);
return useMemo(() => {
return filteredConverse.sort((a, b) => {
return (lastMessageMap[a._id] ?? '') < (lastMessageMap[b._id] ?? '')
? 1
: -1;
});
}, [filteredConverse, lastMessageMap]);
}

View File

@@ -0,0 +1,67 @@
import { useRef } from 'react';
import { useAppDispatch, useAppSelector } from './useAppSelector';
import _debounce from 'lodash/debounce';
import { isLocalMessageId, isValidStr } from '../../utils/string-helper';
import { chatActions } from '../slices';
import { updateAck } from '../../model/converse';
import { useMemoizedFn } from '../../hooks/useMemoizedFn';
const updateAckDebounce = _debounce(
(converseId: string, lastMessageId: string) => {
updateAck(converseId, lastMessageId);
},
1000,
{ leading: true, trailing: true }
);
/**
* 会话已读信息管理
*/
export function useConverseAck(converseId: string) {
const dispatch = useAppDispatch();
const converseLastMessage = useAppSelector(
(state) => state.chat.lastMessageMap[converseId]
);
const lastMessageIdRef = useRef('');
lastMessageIdRef.current = useAppSelector(
(state) => state.chat.ack[converseId] ?? ''
);
const setConverseAck = useMemoizedFn(
(converseId: string, lastMessageId: string) => {
if (isLocalMessageId(lastMessageId)) {
// 跳过本地消息
return;
}
if (
isValidStr(lastMessageIdRef.current) &&
lastMessageId <= lastMessageIdRef.current
) {
// 更新的数字比较小,跳过
return;
}
dispatch(chatActions.setConverseAck({ converseId, lastMessageId }));
updateAckDebounce(converseId, lastMessageId);
lastMessageIdRef.current = lastMessageId;
}
);
/**
* 更新会话最新消息
*/
const updateConverseAck = useMemoizedFn((lastMessageId: string) => {
setConverseAck(converseId, lastMessageId);
});
/**
* 标记为会话已读
*/
const markConverseAllAck = useMemoizedFn(() => {
updateConverseAck(converseLastMessage);
});
return { updateConverseAck, markConverseAllAck };
}

View File

@@ -0,0 +1,227 @@
import { useEffect } from 'react';
import { ensureDMConverse } from '../../helper/converse-helper';
import { useAsync } from '../../hooks/useAsync';
import { showErrorToasts } from '../../manager/ui';
import {
fetchConverseMessage,
sendMessage,
SendMessagePayload,
} from '../../model/message';
import { chatActions } from '../slices';
import { useAppDispatch, useAppSelector } from './useAppSelector';
import _get from 'lodash/get';
import _isNil from 'lodash/isNil';
import _uniqueId from 'lodash/uniqueId';
import {
ChatConverseState,
isValidStr,
t,
useAsyncRequest,
useChatBoxContext,
useEvent,
useMemoizedFn,
} from '../..';
import { MessageHelper } from '../../utils/message-helper';
import { ChatConverseType } from '../../model/converse';
import { sharedEvent } from '../../event';
import { useUpdateRef } from '../../hooks/useUpdateRef';
const genLocalMessageId = () => _uniqueId('localMessage_');
function useHandleSendMessage() {
const userId = useAppSelector((state) => state.user.info?._id);
const dispatch = useAppDispatch();
const { hasContext, replyMsg, clearReplyMsg } = useChatBoxContext();
const replyMsgRef = useUpdateRef(replyMsg); // NOTICE: 这个是为了修复一个边界case: 当先输入文本再选中消息回复时,直接发送无法带上回复信息
/**
* 发送消息
*/
const handleSendMessage = useEvent((payload: SendMessagePayload) => {
// 输入合法性检测
if (payload.content === '') {
showErrorToasts(t('无法发送空消息'));
return;
}
if (hasContext === true) {
// 如果有上下文, 则组装payload
const msgHelper = new MessageHelper(payload);
if (!_isNil(replyMsgRef.current)) {
msgHelper.setReplyMsg(replyMsgRef.current);
clearReplyMsg();
}
payload = msgHelper.generatePayload();
}
const localMessageId = genLocalMessageId();
dispatch(
chatActions.appendLocalMessage({
author: userId,
localMessageId,
payload,
})
);
sendMessage(payload)
.then((message) => {
dispatch(
chatActions.deleteMessageById({
converseId: payload.converseId,
messageId: localMessageId,
})
);
dispatch(
chatActions.appendConverseMessage({
converseId: payload.converseId,
messages: [message],
})
); // 确保删除消息后会立即显示消息适用于网络卡顿的情况同时远程的socket会接收到消息广播在redux中会自动去重
sharedEvent.emit('sendMessage', payload);
})
.catch((err) => {
showErrorToasts(err);
dispatch(
chatActions.updateMessageInfo({
messageId: localMessageId,
message: {
sendFailed: true,
},
})
);
});
});
return handleSendMessage;
}
/**
* 会话消息管理
*/
interface ConverseContext {
converseId: string;
isGroup: boolean;
}
export function useConverseMessage(context: ConverseContext) {
const { converseId, isGroup } = context;
const converse = useAppSelector<ChatConverseState | undefined>(
(state) => state.chat.converses[converseId]
);
const reconnectNum = useAppSelector((state) => state.global.reconnectNum);
const hasMoreMessage = converse?.hasMoreMessage ?? true;
const dispatch = useAppDispatch();
const messages = converse?.messages ?? [];
const currentUserId = useAppSelector((state) => state.user.info?._id);
useEffect(() => {
dispatch(chatActions.updateCurrentConverseId(converseId));
return () => {
dispatch(chatActions.updateCurrentConverseId(null));
};
}, [converseId]);
// NOTICE: 该hook只会在converseId变化和重新链接时执行
const { loading, error } = useAsync(async () => {
if (!currentUserId) {
// 如果当前用户不存在则跳过逻辑
return;
}
if (!converse) {
// 如果是一个新会话(或者当前会话列表中没有)
if (!isGroup) {
// 如果是私信会话
// Step 1. 创建会话 并确保私信列表中存在该会话
const converse = await ensureDMConverse(converseId, currentUserId);
dispatch(chatActions.setConverseInfo(converse));
} else {
// 如果是群组会话(文本频道)
// Step 1. 确保群组会话存在
dispatch(
chatActions.setConverseInfo({
_id: converseId,
name: '',
type: ChatConverseType.Group,
members: [],
})
);
}
// Step 2. 拉取消息
const historyMessages = await fetchConverseMessage(converseId);
dispatch(
chatActions.initialHistoryMessage({
converseId,
historyMessages,
})
);
} else {
// 已存在会话
if (!converse.hasFetchedHistory) {
// 没有获取过历史消息
// 拉取历史消息
const startId = _isNil(converse.messages[0])
? undefined
: converse.messages[0]._id;
const messages = await fetchConverseMessage(converseId, startId);
dispatch(
chatActions.initialHistoryMessage({
converseId,
historyMessages: messages,
})
);
}
}
}, [converseId, reconnectNum, currentUserId]);
// 加载更多消息
const [{ loading: isLoadingMore }, _handleFetchMoreMessage] =
useAsyncRequest(async () => {
const firstMessageId = _get(messages, [0, '_id']);
if (!isValidStr(firstMessageId)) {
return;
}
if (hasMoreMessage === false) {
return;
}
const olderMessages = await fetchConverseMessage(
converseId,
firstMessageId
);
dispatch(
chatActions.appendHistoryMessage({
converseId,
historyMessages: olderMessages,
})
);
}, [converseId, hasMoreMessage, _get(messages, [0, '_id'])]);
/**
* 加载更多
* 同一时间只能请求一次
*/
const handleFetchMoreMessage = useMemoizedFn(async () => {
if (isLoadingMore) {
return;
}
await _handleFetchMoreMessage();
});
const handleSendMessage = useHandleSendMessage();
return {
messages,
loading,
error,
isLoadingMore,
hasMoreMessage,
handleFetchMoreMessage,
handleSendMessage,
};
}

View File

@@ -0,0 +1,23 @@
import { getDMConverseName } from '../../helper/converse-helper';
import { isValidStr, useAppSelector, useAsync } from '../../index';
import type { ChatConverseState } from '../slices/chat';
import { useUserId } from './useUserInfo';
import type { FriendInfo } from '../slices/user';
export function useDMConverseName(converse: ChatConverseState | undefined) {
const userId = useUserId();
const friends: FriendInfo[] = useAppSelector((state) => state.user.friends);
const { value: name = '' } = useAsync(async () => {
if (!converse) {
return '';
}
if (!isValidStr(userId)) {
return '';
}
return getDMConverseName(userId, converse);
}, [userId, converse?.name, converse?.members.join(','), friends]);
return name;
}

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { buildFriendNicknameMap } from '../../helper/converse-helper';
import { isValidStr } from '../../utils/string-helper';
import { useAppSelector } from './useAppSelector';
/**
* 获取好友自定义的昵称
* 用于覆盖原始昵称
*
* @param userId 用户id
*/
export function useFriendNickname(userId: string): string | null {
const nickname = useAppSelector(
(state) => state.user.friends.find((f) => f.id === userId)?.nickname
);
if (isValidStr(nickname)) {
return nickname;
}
return null;
}
export function useFriendNicknameMap(): Record<string, string> {
const friends = useAppSelector((state) => state.user.friends);
const friendNicknameMap = useMemo(
() => buildFriendNicknameMap(friends),
[friends]
);
return friendNicknameMap;
}

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { useUserInfoList } from '../..';
import type { GroupInfo, GroupPanel } from '../../model/group';
import type { UserBaseInfo } from '../../model/user';
import { isValidStr } from '../../utils/string-helper';
import { useAppSelector } from './useAppSelector';
import { useUnread } from './useUnread';
import { useUserId } from './useUserInfo';
import _compact from 'lodash/compact';
/**
* 获取群组信息
*/
export function useGroupInfo(groupId: string): GroupInfo | null {
return useAppSelector((state) => state.group.groups[groupId]) ?? null;
}
/**
* 获取群组中所有成员的uuid列表
*/
export function useGroupMemberIds(groupId: string): string[] {
const groupInfo = useGroupInfo(groupId);
const members = groupInfo?.members ?? [];
const groupMemberIds = useMemo(() => members.map((m) => m.userId), [members]);
return groupMemberIds;
}
/**
* 获取群组中成员信息的详细列表
*/
export function useGroupMemberInfos(groupId: string): UserBaseInfo[] {
const groupMemberIds = useGroupMemberIds(groupId);
const userInfos = useUserInfoList(groupMemberIds);
return _compact(userInfos); // 开发环境可能会出现member里面id为不存在的脏数据生产环境原则上不会出现兼容一下
}
/**
* 获取群组面板列表
*/
export function useGroupPanels(groupId: string): GroupPanel[] {
const groupInfo = useGroupInfo(groupId);
return useMemo(() => groupInfo?.panels ?? [], [groupInfo]);
}
/**
* 获取群组面板信息
*/
export function useGroupPanelInfo(
groupId: string,
panelId: string
): GroupPanel | null {
const panels = useGroupPanels(groupId);
return useMemo(
() => panels.find((p) => p.id === panelId) ?? null,
[groupId, panelId, panels]
);
}
/**
* 检查是否为群组的所有者
* @param userId 群组id 必填
* @param userId 用户id 不传则为当前用户id
*/
export function useIsGroupOwner(groupId: string, userId?: string): boolean {
const groupInfo = useGroupInfo(groupId);
const selfUserId = useUserId();
if (isValidStr(userId)) {
return groupInfo?.owner === userId;
} else {
return typeof selfUserId === 'string' && groupInfo?.owner === selfUserId;
}
}
/**
* 检查群组聊天面板是否有未读消息
* @param textPanelId 文字面板id
*/
export function useGroupTextPanelUnread(textPanelId: string): boolean {
const unread = useUnread([textPanelId]);
return unread[0];
}

View File

@@ -0,0 +1,31 @@
import { useMemoizedFn } from '../../hooks/useMemoizedFn';
import { updateAck } from '../../model/converse';
import { isConversePanel } from '../../utils/panel-helper';
import { chatActions } from '../slices';
import { useAppDispatch, useAppSelector } from './useAppSelector';
import { useGroupInfo } from './useGroup';
/**
* 群组级别的已读管理
*/
export function useGroupAck(groupId: string) {
const groupInfo = useGroupInfo(groupId);
const lastMessageMap = useAppSelector((state) => state.chat.lastMessageMap);
const dispatch = useAppDispatch();
const markGroupAllAck = useMemoizedFn(() => {
const conversePanels = (groupInfo?.panels ?? []).filter(isConversePanel);
for (const converse of conversePanels) {
const converseId = converse.id;
const lastMessageId = lastMessageMap[converseId];
if (converseId && lastMessageId) {
dispatch(chatActions.setConverseAck({ converseId, lastMessageId }));
updateAck(converseId, lastMessageId);
}
}
});
return { markGroupAllAck };
}

View File

@@ -0,0 +1,24 @@
import { useAppSelector } from './useAppSelector';
/**
* 获取用户禁言状态
* @param groupId 群组ID
* @param userId 用户ID
* @returns 如果没有禁言状态或者有禁言但是已过期则返回false否则返回禁言到的时间
*/
export function useGroupMemberMute(
groupId: string,
userId: string
): string | false {
const muteUntil = useAppSelector(
(state) =>
state.group.groups[groupId]?.members.find((m) => m.userId === userId)
?.muteUntil
);
if (!muteUntil || new Date(muteUntil).valueOf() < new Date().valueOf()) {
return false;
}
return muteUntil;
}

View File

@@ -0,0 +1,137 @@
import { useGroupInfo } from './useGroup';
import { useUserId } from './useUserInfo';
import _uniq from 'lodash/uniq';
import _flatten from 'lodash/flatten';
import { useDebugValue, useMemo } from 'react';
import { getPermissionList } from '../..';
/**
* 获取群组用户的所有权限
*/
export function useGroupMemberAllPermissions(groupId: string): string[] {
const groupInfo = useGroupInfo(groupId);
const userId = useUserId();
if (!groupInfo || !userId) {
return [];
}
if (groupInfo.owner === userId) {
// 群组管理员拥有一切权限
// 返回所有权限
return getPermissionList().map((p) => p.key);
}
const members = groupInfo.members;
const groupRoles = groupInfo.roles;
const userRoles = members.find((m) => m.userId === userId)?.roles ?? [];
const userPermissions = _uniq([
..._flatten(
userRoles.map(
(roleId) =>
groupRoles.find((role) => String(role._id) === roleId)?.permissions ??
[]
)
),
...groupInfo.fallbackPermissions,
]);
useDebugValue({
groupRoles,
userRoles,
userPermissions,
fallbackPermissions: groupInfo.fallbackPermissions,
});
return userPermissions;
}
/**
* 获取面板的所有权限
* 不包含群组本身的权限
*/
export function useGroupPanelMemberAllPermissions(
groupId: string,
panelId: string
): string[] {
const groupInfo = useGroupInfo(groupId);
const userId = useUserId();
if (!groupInfo || !userId) {
return [];
}
const panelInfo = groupInfo.panels.find((p) => p.id === panelId);
if (!panelInfo) {
return [];
}
const fallbackPermissions = panelInfo.fallbackPermissions ?? [];
const permissionMap = panelInfo.permissionMap ?? {};
const specPermissions = permissionMap[userId] ?? [];
const userRoles =
groupInfo.members.find((m) => m.userId === userId)?.roles ?? []; // 当前用户角色
const userPanelPermissions = _uniq([
..._flatten(userRoles.map((roleId) => permissionMap[roleId] ?? [])),
...specPermissions,
...fallbackPermissions,
]);
return userPanelPermissions;
}
/**
* 判断用户是否拥有以下权限
*/
export function useHasGroupPermission(
groupId: string,
permissions: string[]
): boolean[] {
const userPermissions = useGroupMemberAllPermissions(groupId);
const result = useMemo(
() => permissions.map((p) => userPermissions.includes(p)),
[userPermissions.join(','), permissions.join(',')]
);
useDebugValue({
groupId,
userPermissions,
checkedPermissions: permissions,
result,
});
return result;
}
/**
* 判断用户是否在某个面板下拥有以下权限
* 用于面板权限控制
*/
export function useHasGroupPanelPermission(
groupId: string,
panelId: string,
permissions: string[]
) {
const groupPermissions = useGroupMemberAllPermissions(groupId);
const panelPermissions = useGroupPanelMemberAllPermissions(groupId, panelId);
const fullPermissions = _uniq([...groupPermissions, ...panelPermissions]);
const result = useMemo(
() => permissions.map((p) => fullPermissions.includes(p)),
[fullPermissions.join(','), permissions.join(',')]
);
useDebugValue({
groupId,
panelId,
fullPermissions,
checkedPermissions: permissions,
result,
});
return result;
}

View File

@@ -0,0 +1,18 @@
import type { InboxItem } from '../../model/inbox';
import { useAppSelector } from './useAppSelector';
/**
* 返回收件箱列表
*/
export function useInboxList(): InboxItem[] {
return useAppSelector((state) => state.chat.inbox ?? []);
}
/**
* 返回收件箱某一项的值
*/
export function useInboxItem(inboxItemId: string): InboxItem | null {
const list = useInboxList();
return list.find((item) => item._id === inboxItemId) ?? null;
}

View File

@@ -0,0 +1,32 @@
import { useAppSelector } from './useAppSelector';
import { useAckInfoChecker } from './useAckInfo';
import { useEffect } from 'react';
/**
* 返回某些会话是否有未读信息
*/
export function useUnread(converseIds: string[]) {
const ack = useAppSelector((state) => state.chat.ack);
const lastMessageMap = useAppSelector((state) => state.chat.lastMessageMap);
const { ensureAckInfo } = useAckInfoChecker();
useEffect(() => {
converseIds.forEach((converseId) => ensureAckInfo(converseId));
}, [converseIds]);
const unreadList = converseIds.map((converseId) => {
if (
ack[converseId] === undefined &&
lastMessageMap[converseId] !== undefined
) {
// 远程没有已读记录且获取到了最后一条消息
return true;
}
// 当远端最后一条消息的id > 本地已读状态的最后一条消息id,
// 则返回true(有未读消息)
return lastMessageMap[converseId] > ack[converseId];
});
return unreadList;
}

View File

@@ -0,0 +1,16 @@
import type { UserLoginInfo } from '../../model/user';
import { useAppSelector } from './useAppSelector';
/**
* 获取当前用户基本信息
*/
export function useUserInfo(): UserLoginInfo | null {
return useAppSelector((state) => state.user.info);
}
/**
* 用户基本Id
*/
export function useUserId(): string | undefined {
return useUserInfo()?._id;
}