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

View File

@@ -0,0 +1,274 @@
import type { AppStore } from './store';
import type { AppSocket } from '../api/socket';
import {
chatActions,
globalActions,
groupActions,
userActions,
} from './slices';
import type { FriendRequest } from '../model/friend';
import { getCachedConverseInfo } from '../cache/cache';
import type { GroupInfo } from '../model/group';
import type { ChatMessage, ChatMessageReaction } from '../model/message';
import { socketEventListeners } from '../manager/socket';
import { showToasts } from '../manager/ui';
import { t } from '../i18n';
import { ChatConverseInfo, ChatConverseType } from '../model/converse';
import { appendUserDMConverse } from '../model/user';
import { sharedEvent } from '../event';
import type { InboxItem } from '../model/inbox';
import { useGlobalConfigStore } from '../store/globalConfig';
import type { GlobalConfig } from '../model/config';
/**
* 初始化 Redux 上下文
* 该文件用于处理远程数据与本地 Redux 状态的交互
*/
export function setupRedux(socket: AppSocket, store: AppStore) {
store.dispatch(globalActions.setNetworkStatus('initial'));
initial(socket, store);
listenNotify(socket, store);
// 断线重连重新初始化信息
socket.onReconnect(() => {
console.warn('因为断线重连触发重新同步远程数据');
initial(socket, store);
/**
* 重置会话列表
* 如果当前已经打开了一个会话列表则会让该会话自行更新(由useConverseMessage保障)
*/
store.dispatch(chatActions.clearAllConverses());
store.dispatch(globalActions.incReconnectNum());
});
sharedEvent.on('updateNetworkStatus', (status) => {
store.dispatch(globalActions.setNetworkStatus(status));
});
}
/**
* 初始化数据
*/
function initial(socket: AppSocket, store: AppStore) {
console.log('初始化Redux上下文...');
// 立即请求加入房间
socket
.request<{
dmConverseIds: string[];
groupIds: string[];
textPanelIds: string[];
subscribeFeaturePanelIds: string[];
}>('chat.converse.findAndJoinRoom')
.catch((err) => {
console.error(err);
showToasts(
t('无法加入房间, 您将无法获取到最新的信息, 请刷新页面后重试'),
'error'
);
throw new Error('findAndJoinRoom failed');
});
// 获取好友列表
socket
.request<{ id: string; nickname?: string }[]>('friend.getAllFriends')
.then((data) => {
store.dispatch(userActions.setFriendList(data));
});
// 获取好友邀请列表
socket.request<FriendRequest[]>('friend.request.allRelated').then((data) => {
store.dispatch(userActions.setFriendRequests(data));
});
// 获取所有的当前用户会话列表
socket.request<string[]>('user.dmlist.getAllConverse').then((data) => {
(data ?? []).forEach(async (converseId) => {
// TODO: 待优化, 可以在后端一次性返回
try {
const converse = await getCachedConverseInfo(converseId);
store.dispatch(chatActions.setConverseInfo(converse));
} catch (e) {
console.error(e);
}
});
});
/**
* 获取用户群组列表
*/
socket.request<GroupInfo[]>('group.getUserGroups').then((groups) => {
store.dispatch(groupActions.appendGroups(groups));
});
socket.request<InboxItem[]>('chat.inbox.all').then((list) => {
store.dispatch(chatActions.setInboxList(list));
});
}
/**
* 监听远程通知
*/
function listenNotify(socket: AppSocket, store: AppStore) {
socket.listen<{ userId: string }>('friend.add', ({ userId }) => {
if (typeof userId !== 'string') {
console.error('错误的信息', userId);
return;
}
store.dispatch(userActions.appendFriend({ id: userId }));
});
socket.listen<FriendRequest>('friend.request.add', (request) => {
store.dispatch(userActions.appendFriendRequest(request));
});
socket.listen<{ requestId: string }>(
'friend.request.remove',
({ requestId }) => {
store.dispatch(userActions.removeFriendRequest(requestId));
}
);
socket.listen<ChatMessage>('chat.message.add', (message) => {
// 处理接受到的消息
const converseId = message.converseId;
const converse = store.getState().chat.converses[converseId];
// 添加消息到会话中
const appendMessage = () => {
store.dispatch(
chatActions.appendConverseMessage({
converseId,
messages: [message],
})
);
};
if (converse) {
// 如果该会话已经加载(群组面板)
appendMessage();
} else if (!message.groupId) {
// 如果会话没有加载, 但是是私信消息
// 则获取会话信息后添加到会话消息中
getCachedConverseInfo(converseId).then((converse) => {
if (
[ChatConverseType.DM, ChatConverseType.Multi].includes(converse.type)
) {
// 如果是私人会话, 则添加到dmlist
appendUserDMConverse(converse._id);
}
store.dispatch(chatActions.setConverseInfo(converse));
appendMessage();
});
} else {
// 是群组未加载的消息面板的消息
// 设置会话信息
store.dispatch(
chatActions.setLastMessageMap([
{
converseId,
lastMessageId: message._id,
},
])
);
}
sharedEvent.emit('receiveMessage', message); // 推送到通知中心
});
socket.listen<ChatMessage>('chat.message.update', (message) => {
store.dispatch(
chatActions.updateMessageInfo({
message,
})
);
});
socket.listen<{
converseId: string;
messageId: string;
}>('chat.message.delete', ({ converseId, messageId }) => {
store.dispatch(
chatActions.deleteMessageById({
converseId,
messageId,
})
);
});
socket.listen<{
converseId: string;
messageId: string;
reaction: ChatMessageReaction;
}>('chat.message.addReaction', ({ converseId, messageId, reaction }) => {
store.dispatch(
chatActions.appendMessageReaction({
converseId,
messageId,
reaction,
})
);
});
socket.listen<{
converseId: string;
messageId: string;
reaction: ChatMessageReaction;
}>('chat.message.removeReaction', ({ converseId, messageId, reaction }) => {
store.dispatch(
chatActions.removeMessageReaction({
converseId,
messageId,
reaction,
})
);
});
socket.listen<ChatConverseInfo>(
'chat.converse.updateDMConverse',
(converse) => {
store.dispatch(chatActions.setConverseInfo(converse));
}
);
socket.listen<GroupInfo>('group.add', (groupInfo) => {
store.dispatch(groupActions.appendGroups([groupInfo]));
});
socket.listen<GroupInfo>('group.updateInfo', (groupInfo) => {
store.dispatch(groupActions.updateGroup(groupInfo));
});
socket.listen<{ groupId: string }>('group.remove', ({ groupId }) => {
store.dispatch(groupActions.removeGroup(groupId));
});
socket.listen<InboxItem>('chat.inbox.append', (item) => {
store.dispatch(chatActions.appendInboxItem(item));
});
socket.listen('chat.inbox.updated', () => {
// 检测到收件箱列表被更新,需要重新获取
socket.request<InboxItem[]>('chat.inbox.all').then((list) => {
store.dispatch(chatActions.setInboxList(list));
});
});
socket.listen(
'config.updateClientConfig',
(config: Partial<GlobalConfig>) => {
useGlobalConfigStore.setState((state) => ({
...state,
...config,
}));
}
);
// 其他的额外的通知
socketEventListeners.forEach(({ eventName, eventFn }) => {
socket.listen(eventName, eventFn);
});
}

View File

@@ -0,0 +1,414 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { ChatConverseInfo } from '../../model/converse';
import type {
ChatMessage,
ChatMessageReaction,
LocalChatMessage,
SendMessagePayload,
} from '../../model/message';
import _uniqBy from 'lodash/uniqBy';
import _orderBy from 'lodash/orderBy';
import _last from 'lodash/last';
import { isLocalMessageId, isValidStr } from '../../utils/string-helper';
import type { InboxItem } from '../../model/inbox';
export interface ChatConverseState extends ChatConverseInfo {
messages: LocalChatMessage[];
hasFetchedHistory: boolean;
/**
* 判定是否还有更多的信息
*/
hasMoreMessage: boolean;
}
export interface ChatState {
currentConverseId: string | null; // 当前活跃的会话id
converses: Record<string, ChatConverseState>; // <会话Id, 会话信息>
ack: Record<string, string>; // <会话Id, 本地最后一条会话Id>
inbox: InboxItem[];
/**
* 会话最新消息mapping
* <会话Id, 远程会话列表最后一条会话Id>
*/
lastMessageMap: Record<string, string>;
}
const initialState: ChatState = {
currentConverseId: null,
converses: {},
ack: {},
inbox: [],
lastMessageMap: {},
};
const chatSlice = createSlice({
name: 'chat',
initialState,
reducers: {
updateCurrentConverseId(state, action: PayloadAction<string | null>) {
state.currentConverseId = action.payload;
},
/**
* 设置会话信息
*/
setConverseInfo(state, action: PayloadAction<ChatConverseInfo>) {
const converseId = action.payload._id;
const originInfo = state.converses[converseId]
? { ...state.converses[converseId] }
: { messages: [], hasFetchedHistory: false, hasMoreMessage: true };
state.converses[converseId] = {
...originInfo,
...action.payload,
};
},
/**
* 追加消息
* 会根据id进行一次排序以确保顺序
*/
appendConverseMessage(
state,
action: PayloadAction<{
converseId: string;
messages: ChatMessage[];
}>
) {
const { converseId, messages } = action.payload;
if (!state.converses[converseId]) {
// 没有会话信息, 请先设置会话信息
console.error('没有会话信息, 请先设置会话信息');
return;
}
// NOTICE: 按照该规则能确保本地消息一直在最后因为l大于任何ObjectId
const newMessages = _orderBy(
_uniqBy([...state.converses[converseId].messages, ...messages], '_id'),
'_id',
'asc'
);
state.converses[converseId].messages = newMessages;
/**
* 如果在当前会话中,则暂时不更新最后收到的消息的本地状态,避免可能出现的瞬间更新最后消息(出现小红点) 但是会立即已读(小红点消失)
* 所以仅对非当前会话的消息进行更新最后消息
*/
if (state.currentConverseId !== converseId) {
const lastMessageId = _last(
newMessages.filter((m) => !isLocalMessageId(m._id))
)?._id;
if (isValidStr(lastMessageId)) {
state.lastMessageMap[converseId] = lastMessageId;
}
}
},
/**
* 追加本地消息消息
*/
appendLocalMessage(
state,
action: PayloadAction<{
author?: string;
localMessageId: string;
payload: SendMessagePayload;
}>
) {
const { author, localMessageId, payload } = action.payload;
const { converseId, groupId, content, meta } = payload;
if (!state.converses[converseId]) {
// 没有会话信息, 请先设置会话信息
console.error('没有会话信息, 请先设置会话信息');
return;
}
const message: LocalChatMessage = {
_id: localMessageId,
author,
groupId,
converseId,
content,
meta: meta as Record<string, unknown>,
isLocal: true,
};
const newMessages = _orderBy(
_uniqBy([...state.converses[converseId].messages, message], '_id'),
'_id',
'asc'
);
state.converses[converseId].messages = newMessages;
},
/**
* 初始化历史信息
*/
initialHistoryMessage(
state,
action: PayloadAction<{
converseId: string;
historyMessages: ChatMessage[];
}>
) {
const { converseId, historyMessages } = action.payload;
if (!state.converses[converseId]) {
// 没有会话信息, 请先设置会话信息
console.error('没有会话信息, 请先设置会话信息');
return;
}
chatSlice.caseReducers.appendConverseMessage(
state,
chatSlice.actions.appendConverseMessage({
converseId,
messages: [...historyMessages],
})
);
if (historyMessages.length < 50) {
state.converses[converseId].hasMoreMessage = false;
}
state.converses[converseId].hasFetchedHistory = true;
},
/**
* 追加历史信息
*/
appendHistoryMessage(
state,
action: PayloadAction<{
converseId: string;
historyMessages: ChatMessage[];
}>
) {
const { converseId, historyMessages } = action.payload;
if (!state.converses[converseId]) {
// 没有会话信息, 请先设置会话信息
console.error('没有会话信息, 请先设置会话信息');
return;
}
chatSlice.caseReducers.appendConverseMessage(
state,
chatSlice.actions.appendConverseMessage({
converseId,
messages: [...historyMessages],
})
);
if (historyMessages.length < 50) {
state.converses[converseId].hasMoreMessage = false;
}
state.converses[converseId].hasFetchedHistory = true;
},
removeConverse(state, action: PayloadAction<{ converseId: string }>) {
const { converseId } = action.payload;
if (!state.converses[converseId]) {
return;
}
delete state.converses[converseId];
},
/**
* 清理所有会话信息
*/
clearAllConverses(state) {
state.converses = {};
},
/**
* 设置已读消息
*/
setConverseAck(
state,
action: PayloadAction<{
converseId: string;
lastMessageId: string;
}>
) {
const { converseId, lastMessageId } = action.payload;
state.ack[converseId] = lastMessageId;
},
/**
* 更新消息信息
*/
updateMessageInfo(
state,
action: PayloadAction<{
messageId?: string;
message: Partial<LocalChatMessage>;
}>
) {
const { message } = action.payload;
const messageId = action.payload.messageId ?? message._id;
const converseId = message.converseId;
if (!converseId) {
console.warn('Not found converse id,', message);
return;
}
const converse = state.converses[converseId];
if (!converse) {
console.warn('Not found converse,', converseId);
return;
}
const index = converse.messages.findIndex((m) => m._id === messageId);
if (index >= 0) {
converse.messages[index] = {
...converse.messages[index],
...message,
};
}
},
/**
* 删除消息
*/
deleteMessageById(
state,
action: PayloadAction<{
converseId: string;
messageId: string;
}>
) {
const { converseId, messageId } = action.payload;
const converse = state.converses[converseId];
if (!converse) {
console.warn('Not found converse,', converseId);
return;
}
const index = converse.messages.findIndex((m) => m._id === messageId);
if (index >= 0) {
converse.messages.splice(index, 1);
}
},
/**
* 设置远程的最后一条会话的id
*/
setLastMessageMap(
state,
action: PayloadAction<
{
converseId: string;
lastMessageId: string;
}[]
>
) {
const list = action.payload;
if (Array.isArray(list)) {
list.forEach((item) => {
state.lastMessageMap[item.converseId] = item.lastMessageId;
});
}
},
/**
* 追加消息反应
*/
appendMessageReaction(
state,
action: PayloadAction<{
converseId: string;
messageId: string;
reaction: ChatMessageReaction;
}>
) {
const { converseId, messageId, reaction } = action.payload;
const converse = state.converses[converseId];
if (!converse) {
console.warn('Not found converse,', converseId);
return;
}
const message = converse.messages.find((m) => m._id === messageId);
if (!message) {
console.warn('Not found message,', messageId);
return;
}
if (!Array.isArray(message.reactions)) {
message.reactions = [];
}
message.reactions.push(reaction);
},
/**
* 移除消息反应
*/
removeMessageReaction(
state,
action: PayloadAction<{
converseId: string;
messageId: string;
reaction: ChatMessageReaction;
}>
) {
const { converseId, messageId, reaction } = action.payload;
const converse = state.converses[converseId];
if (!converse) {
console.warn('Not found converse,', converseId);
return;
}
const message = converse.messages.find((m) => m._id === messageId);
if (!message) {
console.warn('Not found message,', messageId);
return;
}
if (!Array.isArray(message.reactions)) {
message.reactions = [];
}
const reactionIndex = message.reactions.findIndex(
(r) => r.name === reaction.name && r.author === reaction.author
);
message.reactions.splice(reactionIndex, 1);
},
/**
* 设置收件箱
*/
setInboxList(state, action: PayloadAction<InboxItem[]>) {
const list = action.payload;
state.inbox = list;
},
/**
* 增加收件箱项目
*/
appendInboxItem(state, action: PayloadAction<InboxItem>) {
state.inbox.push(action.payload);
},
/**
* 设置收件箱
*/
setInboxItemAck(state, action: PayloadAction<string>) {
const inboxItemId = action.payload;
const item = state.inbox.find((item) => item._id === inboxItemId);
if (item) {
item.readed = true;
}
},
},
});
export const chatActions = chatSlice.actions;
export const chatReducer = chatSlice.reducer;

View File

@@ -0,0 +1,33 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface GlobalState {
/**
* 网络状态
*/
networkStatus: 'initial' | 'connected' | 'reconnecting' | 'disconnected';
reconnectNum: number;
}
const initialState: GlobalState = {
networkStatus: 'initial',
reconnectNum: 0,
};
const globalSlice = createSlice({
name: 'global',
initialState,
reducers: {
setNetworkStatus(
state,
action: PayloadAction<GlobalState['networkStatus']>
) {
state.networkStatus = action.payload;
},
incReconnectNum(state) {
state.reconnectNum += 1;
},
},
});
export const globalActions = globalSlice.actions;
export const globalReducer = globalSlice.reducer;

View File

@@ -0,0 +1,103 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { GroupInfo } from '../../model/group';
export interface GroupState {
groups: Record<string, GroupInfo>;
}
const initialState: GroupState = {
groups: {},
};
const groupSlice = createSlice({
name: 'group',
initialState,
reducers: {
/**
* 追加或更新群组信息
*/
appendGroups(state, action: PayloadAction<GroupInfo[]>) {
const groups = action.payload;
for (const group of groups) {
state.groups[group._id] = {
...state.groups[group._id],
...group,
};
}
},
updateGroup(state, action: PayloadAction<GroupInfo>) {
const group = action.payload;
const groupId = group._id;
if (state.groups[groupId]) {
// NOTICE: updateGroup 只会去更新,不会去添加新的
state.groups[groupId] = {
...state.groups[groupId],
...group,
};
}
},
removeGroup(state, action: PayloadAction<string>) {
const groupId = action.payload;
delete state.groups[groupId];
},
pinGroupPanel(
state,
action: PayloadAction<{
groupId: string;
panelId: string;
}>
) {
const { groupId, panelId } = action.payload;
if (state.groups[groupId]) {
// NOTICE: updateGroup 只会去更新,不会去添加新的
state.groups[groupId] = {
...state.groups[groupId],
pinnedPanelId: panelId,
};
}
},
unpinGroupPanel(
state,
action: PayloadAction<{
groupId: string;
}>
) {
const { groupId } = action.payload;
if (state.groups[groupId]) {
// NOTICE: updateGroup 只会去更新,不会去添加新的
state.groups[groupId] = {
...state.groups[groupId],
pinnedPanelId: undefined,
};
}
},
updateGroupConfig(
state,
action: PayloadAction<{
groupId: string;
configName: string;
configValue: any;
}>
) {
const { groupId, configName, configValue } = action.payload;
const groupInfo = state.groups[groupId];
if (groupInfo) {
state.groups[groupId] = {
...groupInfo,
config: {
...(groupInfo.config ?? {}),
[configName]: configValue,
},
};
}
},
},
});
export const groupActions = groupSlice.actions;
export const groupReducer = groupSlice.reducer;

View File

@@ -0,0 +1,22 @@
import { combineReducers } from '@reduxjs/toolkit';
import { userReducer } from './user';
import { chatReducer } from './chat';
import { groupReducer } from './group';
import { uiReducer } from './ui';
import { globalReducer } from './global';
export const appReducer = combineReducers({
global: globalReducer,
user: userReducer,
chat: chatReducer,
group: groupReducer,
ui: uiReducer,
});
export type AppState = ReturnType<typeof appReducer>;
export { globalActions } from './global';
export { userActions } from './user';
export { chatActions } from './chat';
export { groupActions } from './group';
export { uiActions } from './ui';

View File

@@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface UIState {
panelWinUrls: string[];
}
const initialState: UIState = {
panelWinUrls: [],
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
addPanelWindowUrl(state, action: PayloadAction<{ url: string }>) {
const panelUrl = action.payload.url;
state.panelWinUrls.push(panelUrl);
},
deletePanelWindowUrl(state, action: PayloadAction<{ url: string }>) {
const panelUrl = action.payload.url;
const index = state.panelWinUrls.indexOf(panelUrl);
state.panelWinUrls.splice(index, 1);
},
},
});
export const uiActions = uiSlice.actions;
export const uiReducer = uiSlice.reducer;

View File

@@ -0,0 +1,101 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import _set from 'lodash/set';
import type { UserLoginInfo } from '../../model/user';
import type { FriendRequest } from '../../model/friend';
export interface FriendInfo {
id: string;
nickname?: string;
}
export interface UserState {
info: UserLoginInfo | null;
friends: FriendInfo[]; // 好友的id列表
friendRequests: FriendRequest[];
}
const initialState: UserState = {
info: null,
friends: [],
friendRequests: [],
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUserInfo(state, action: PayloadAction<UserLoginInfo>) {
state.info = action.payload;
},
setUserInfoField(
state,
action: PayloadAction<{ fieldName: keyof UserLoginInfo; fieldValue: any }>
) {
const { fieldName, fieldValue } = action.payload;
if (state.info === null) {
return;
}
_set(state.info, [fieldName], fieldValue);
},
setUserInfoExtra(
state,
action: PayloadAction<{ fieldName: string; fieldValue: any }>
) {
const { fieldName, fieldValue } = action.payload;
if (state.info === null) {
return;
}
_set(state.info, ['extra', fieldName], fieldValue);
},
setFriendList(state, action: PayloadAction<FriendInfo[]>) {
state.friends = action.payload;
},
setFriendRequests(state, action: PayloadAction<FriendRequest[]>) {
state.friendRequests = action.payload;
},
appendFriend(state, action: PayloadAction<FriendInfo>) {
if (state.friends.some((id) => id === action.payload)) {
return;
}
state.friends.push(action.payload);
},
removeFriend(state, action: PayloadAction<string>) {
const friendId = action.payload;
const index = state.friends.findIndex((item) => item.id === friendId);
if (index >= 0) {
state.friends.splice(index, 1);
}
},
appendFriendRequest(state, action: PayloadAction<FriendRequest>) {
if (state.friendRequests.some(({ _id }) => _id === action.payload._id)) {
return;
}
state.friendRequests.push(action.payload);
},
removeFriendRequest(state, action: PayloadAction<string>) {
const index = state.friendRequests.findIndex(
({ _id }) => _id === action.payload
);
if (index >= 0) {
state.friendRequests.splice(index, 1);
}
},
setFriendNickname(
state,
action: PayloadAction<{ friendId: string; nickname: string }>
) {
const { friendId, nickname } = action.payload;
const target = state.friends.find((f) => f.id === friendId);
if (target) {
target.nickname = nickname;
}
},
},
});
export const userActions = userSlice.actions;
export const userReducer = userSlice.reducer;

View File

@@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit';
import { appReducer } from './slices';
function createStore() {
const store = configureStore({
reducer: appReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}),
devTools: process.env.NODE_ENV !== 'production',
});
return store;
}
const reduxStore = createStore();
export function getReduxStore() {
return reduxStore;
}
export type AppStore = ReturnType<typeof createStore>;
export type AppState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
export { Provider as ReduxProvider } from 'react-redux';