优化
This commit is contained in:
41
client/shared/redux/hooks/useAckInfo.ts
Normal file
41
client/shared/redux/hooks/useAckInfo.ts
Normal 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 };
|
||||
}
|
||||
15
client/shared/redux/hooks/useAppSelector.ts
Normal file
15
client/shared/redux/hooks/useAppSelector.ts
Normal 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>();
|
||||
}
|
||||
31
client/shared/redux/hooks/useConverse.ts
Normal file
31
client/shared/redux/hooks/useConverse.ts
Normal 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]);
|
||||
}
|
||||
67
client/shared/redux/hooks/useConverseAck.ts
Normal file
67
client/shared/redux/hooks/useConverseAck.ts
Normal 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 };
|
||||
}
|
||||
227
client/shared/redux/hooks/useConverseMessage.ts
Normal file
227
client/shared/redux/hooks/useConverseMessage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
23
client/shared/redux/hooks/useDMConverseName.ts
Normal file
23
client/shared/redux/hooks/useDMConverseName.ts
Normal 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;
|
||||
}
|
||||
33
client/shared/redux/hooks/useFriendNickname.ts
Normal file
33
client/shared/redux/hooks/useFriendNickname.ts
Normal 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;
|
||||
}
|
||||
87
client/shared/redux/hooks/useGroup.ts
Normal file
87
client/shared/redux/hooks/useGroup.ts
Normal 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];
|
||||
}
|
||||
31
client/shared/redux/hooks/useGroupAck.ts
Normal file
31
client/shared/redux/hooks/useGroupAck.ts
Normal 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 };
|
||||
}
|
||||
24
client/shared/redux/hooks/useGroupMemberMute.ts
Normal file
24
client/shared/redux/hooks/useGroupMemberMute.ts
Normal 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;
|
||||
}
|
||||
137
client/shared/redux/hooks/useGroupPermission.ts
Normal file
137
client/shared/redux/hooks/useGroupPermission.ts
Normal 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;
|
||||
}
|
||||
18
client/shared/redux/hooks/useInbox.ts
Normal file
18
client/shared/redux/hooks/useInbox.ts
Normal 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;
|
||||
}
|
||||
32
client/shared/redux/hooks/useUnread.ts
Normal file
32
client/shared/redux/hooks/useUnread.ts
Normal 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;
|
||||
}
|
||||
16
client/shared/redux/hooks/useUserInfo.ts
Normal file
16
client/shared/redux/hooks/useUserInfo.ts
Normal 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;
|
||||
}
|
||||
274
client/shared/redux/setup.ts
Normal file
274
client/shared/redux/setup.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
414
client/shared/redux/slices/chat.ts
Normal file
414
client/shared/redux/slices/chat.ts
Normal 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;
|
||||
33
client/shared/redux/slices/global.ts
Normal file
33
client/shared/redux/slices/global.ts
Normal 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;
|
||||
103
client/shared/redux/slices/group.ts
Normal file
103
client/shared/redux/slices/group.ts
Normal 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;
|
||||
22
client/shared/redux/slices/index.ts
Normal file
22
client/shared/redux/slices/index.ts
Normal 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';
|
||||
28
client/shared/redux/slices/ui.ts
Normal file
28
client/shared/redux/slices/ui.ts
Normal 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;
|
||||
101
client/shared/redux/slices/user.ts
Normal file
101
client/shared/redux/slices/user.ts
Normal 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;
|
||||
25
client/shared/redux/store.ts
Normal file
25
client/shared/redux/store.ts
Normal 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';
|
||||
Reference in New Issue
Block a user