diff --git a/client/shared/cache/cache.ts b/client/shared/cache/cache.ts index 1295fd37..25f674c6 100644 --- a/client/shared/cache/cache.ts +++ b/client/shared/cache/cache.ts @@ -52,11 +52,13 @@ export async function getCachedUserInfo( * 获取缓存的会话信息 */ export async function getCachedConverseInfo( - converseId: string + converseId: string, + refetch = false ): Promise { const data = await queryClient.fetchQuery( [CacheKey.converse, converseId], - () => fetchConverseInfo(converseId) + () => fetchConverseInfo(converseId), + { staleTime: refetch ? 0 : 10 * 1000 } ); return data; diff --git a/client/shared/helper/converse-helper.ts b/client/shared/helper/converse-helper.ts index 3c916b8b..257a458c 100644 --- a/client/shared/helper/converse-helper.ts +++ b/client/shared/helper/converse-helper.ts @@ -1,9 +1,73 @@ -import { getReduxStore, isValidStr } from '..'; -import { getCachedConverseInfo, getCachedUserInfo } from '../cache/cache'; +import { getReduxStore, AppStore } from '../redux/store'; +import { isValidStr } from '../utils/string-helper'; +import { CacheKey, getCachedUserInfo } from '../cache/cache'; +import { queryClient } from '../cache'; import { t } from '../i18n'; -import type { ChatConverseInfo } from '../model/converse'; +import { ChatConverseInfo, fetchConverseInfo } from '../model/converse'; import { appendUserDMConverse } from '../model/user'; import type { FriendInfo } from '../redux/slices/user'; +import { chatActions } from '../redux/slices/chat'; +import type { RequestError } from '../api/request'; + +const converseRefreshes = new Map< + string, + Promise +>(); + +export function removeDMConverseLocally( + converseId: string, + store: AppStore = getReduxStore() +) { + store.dispatch(chatActions.removeDMConverse({ converseId })); + converseRefreshes.delete(converseId); + queryClient.removeQueries([CacheKey.converse, converseId], { exact: true }); + queryClient.removeQueries([CacheKey.converseAck, converseId], { + exact: true, + }); +} + +/** + * 只有服务端确认的成员关系才能恢复已退出的会话。 + */ +export function refreshDMConverse( + converseId: string, + currentUserId: string, + store: AppStore = getReduxStore() +): Promise { + const version = + store.getState().chat.converseMembership[converseId]?.version ?? 0; + const run = async (): Promise => { + // 成员验证不复用缓存;同时开始的调用等待最新请求,不互相取消。 + const result = await fetchConverseInfo(converseId).then( + (converse) => ({ converse, error: undefined }), + (error: RequestError) => ({ converse: undefined, error }) + ); + if ( + (store.getState().chat.converseMembership[converseId]?.version ?? 0) !== + version + ) { + return; + } + const latestRefresh = converseRefreshes.get(converseId); + if (latestRefresh && latestRefresh !== refresh) { + return latestRefresh; + } + if (result.error && result.error.code !== 403) { + throw result.error; + } + if (!result.converse?.members.includes(currentUserId)) { + removeDMConverseLocally(converseId, store); + return; + } + store.dispatch( + chatActions.restoreDMConverse({ converse: result.converse, version }) + ); + return result.converse; + }; + const refresh = run(); + converseRefreshes.set(converseId, refresh); + return refresh; +} /** * 确保私信会话存在 @@ -12,18 +76,23 @@ export async function ensureDMConverse( converseId: string, currentUserId: string ): Promise { - const converse = await getCachedConverseInfo(converseId); - if (converse === null) { - // TODO - throw new Error(t('找不到私信会话')); - } - - if (!converse.members.includes(currentUserId)) { + const store = getReduxStore(); + const version = + store.getState().chat.converseMembership[converseId]?.version ?? 0; + const converse = await refreshDMConverse(converseId, currentUserId, store); + if (!converse) { throw new Error(t('会话没有权限')); } await appendUserDMConverse(converseId); // 添加到私人会话列表 + if ( + (store.getState().chat.converseMembership[converseId]?.version ?? 0) !== + version + ) { + throw new Error(t('会话没有权限')); + } + return converse; } @@ -72,7 +141,9 @@ export async function getDMConverseName( }); const len = memberNicknames.length; - if (len === 1) { + if (len === 0) { + return t('多人会话'); + } else if (len === 1) { return memberNicknames[0] ?? ''; } else if (len === 2) { return `${memberNicknames[0]}, ${memberNicknames[1]}`; diff --git a/client/shared/hooks/model/useUserSettings.ts b/client/shared/hooks/model/useUserSettings.ts index 595e1091..343d60c7 100644 --- a/client/shared/hooks/model/useUserSettings.ts +++ b/client/shared/hooks/model/useUserSettings.ts @@ -25,15 +25,21 @@ export function useUserSettings() { const [{ loading: saveLoading }, setSettings] = useAsyncRequest( async (_settings: UserSettings) => { + const previousSettings = + client.getQueryData([CacheKey.userSettings]) ?? {}; client.setQueryData([CacheKey.userSettings], () => ({ - ...settings, + ...previousSettings, ..._settings, })); // 让配置能够立即生效, 防止依赖配置的行为出现跳变(如GroupNav) - const newSettings = await setUserSettings(_settings); - - client.setQueryData([CacheKey.userSettings], () => newSettings); - sharedEvent.emit('userSettingsUpdate', newSettings); + try { + const newSettings = await setUserSettings(_settings); + client.setQueryData([CacheKey.userSettings], () => newSettings); + sharedEvent.emit('userSettingsUpdate', newSettings); + } catch (error) { + client.setQueryData([CacheKey.userSettings], previousSettings); + throw error; + } }, [client] ); diff --git a/client/shared/i18n/langs/en-US/translation.json b/client/shared/i18n/langs/en-US/translation.json index 62999cc9..8afbb812 100644 --- a/client/shared/i18n/langs/en-US/translation.json +++ b/client/shared/i18n/langs/en-US/translation.json @@ -7,6 +7,7 @@ "k11cef91e": "There are some problems with the page", "k123852c": "Group privacy control to prevent malicious harassment of users through groups.", "k1252f904": "Gateway", + "k12e81a9d": "Multi-person conversation", "k131598d0": "A new version is detected, whether to refresh immediately to upgrade to the latest content", "k13ae6a93": "Copy", "k13bea6d2": "User not found", @@ -19,6 +20,7 @@ "k18580d81": "Create a link and send it to external friends", "k186fec4": "Plugin failed to load", "k1885734a": "Effective after refreshing the page", + "k1898e887": "Leave conversation", "k18c716ce": "Password cannot be less than 6 digits", "k19885be1": "Panel name is too long", "k19a1647f": "1 minute", @@ -79,6 +81,7 @@ "k375be8c3": "Jump to panel", "k378f66fc": "Unmute", "k393892b6": "Upload original image", + "k3a1a59b7": "Leave this multi-person conversation? You will no longer receive its messages.", "k3a31dae3": "Verification email sent", "k3ac17670": "An exception occurred, store create failed", "k3b4b656d": "About", @@ -308,6 +311,7 @@ "kb123dbb9": "Your personal unique identifier", "kb12cc88f": "Unmute", "kb2217aa7": "Currently using a temporary account, <2>claim now", + "kb292488d": "When enabled, only friends can invite you to create or join multi-person conversations", "kb3390ae7": "Are you sure you want to leave the group?", "kb47a9aa4": "Password can not be empty", "kb488372f": "All", @@ -356,6 +360,7 @@ "kcac0467c": "Allow members to create invitation links", "kcb20f8ce": "The following are plugin permissions", "kcb8e662f": "Converse Service", + "kccdd14b1": "Close conversation (new messages will show it again)", "kceea83fc": "Join Group", "kcefdbe2d": "Modify avatar success", "kd0690a45": "Allow management of identity groups", @@ -414,6 +419,7 @@ "ke6da074f": "The message was withdrawn successfully", "ke9748e88": "Custom Meta is not supported", "keb053701": "Copy message text successfully", + "kec0ca6b4": "Only allow friends to invite me to conversations", "kec46a57f": "Add members", "kecb51e2c": "Old password", "kecbb0e45": "System", @@ -459,7 +465,6 @@ "kf8de33b7": "Plugin provider not found", "kf9235c11": "Something went wrong", "kf94465ba": "Invite Code", - "kfa01c850": "No private message found", "kfa493f3f": "Reply", "kfa610536": "Alpha mode switch", "kfaddd61d": "Chat Service", diff --git a/client/shared/i18n/langs/zh-CN/translation.json b/client/shared/i18n/langs/zh-CN/translation.json index 1548149f..66ed789e 100644 --- a/client/shared/i18n/langs/zh-CN/translation.json +++ b/client/shared/i18n/langs/zh-CN/translation.json @@ -7,6 +7,7 @@ "k11cef91e": "页面出现了一些问题", "k123852c": "群组隐私控制,防止通过群组恶意骚扰用户。", "k1252f904": "服务网关", + "k12e81a9d": "多人会话", "k131598d0": "检测到有新版本, 是否立即刷新以升级到最新内容", "k13ae6a93": "复制", "k13bea6d2": "没有找到用户", @@ -19,6 +20,7 @@ "k18580d81": "创建链接并发送给外部好友", "k186fec4": "插件加载失败", "k1885734a": "刷新页面后生效", + "k1898e887": "退出会话", "k18c716ce": "密码不能低于6位", "k19885be1": "面板名过长", "k19a1647f": "1分钟", @@ -79,6 +81,7 @@ "k375be8c3": "跳转到面板", "k378f66fc": "解除禁言", "k393892b6": "上传原图", + "k3a1a59b7": "确定要退出此多人会话么?退出后将不再接收此会话的消息", "k3a31dae3": "已发送认证邮件", "k3ac17670": "出现异常, Store 创建失败", "k3b4b656d": "关于", @@ -308,6 +311,7 @@ "kb123dbb9": "您的个人唯一标识", "kb12cc88f": "取消免打扰", "kb2217aa7": "当前使用的是一个临时账号, <2>立即认领", + "kb292488d": "开启后,只有好友可以邀请你创建或加入多人会话", "kb3390ae7": "确定要退出群组么?", "kb47a9aa4": "密码不能为空", "kb488372f": "所有人", @@ -356,6 +360,7 @@ "kcac0467c": "允许成员创建邀请链接", "kcb20f8ce": "以下为插件权限", "kcb8e662f": "会话服务", + "kccdd14b1": "关闭会话(新消息到达时会重新显示)", "kceea83fc": "加入群组", "kcefdbe2d": "修改头像成功", "kd0690a45": "允许管理身份组", @@ -414,6 +419,7 @@ "ke6da074f": "消息撤回成功", "ke9748e88": "不支持自定义Meta", "keb053701": "复制消息文本成功", + "kec0ca6b4": "仅允许好友邀请创建会话", "kec46a57f": "添加成员", "kecb51e2c": "旧密码", "kecbb0e45": "系统", @@ -459,7 +465,6 @@ "kf8de33b7": "未找到插件的提供者", "kf9235c11": "出现了一些问题", "kf94465ba": "邀请码", - "kfa01c850": "找不到私信会话", "kfa493f3f": "回复", "kfa610536": "Alpha测试开关", "kfaddd61d": "聊天服务", diff --git a/client/shared/model/converse.ts b/client/shared/model/converse.ts index 7445c04a..7daf8f2a 100644 --- a/client/shared/model/converse.ts +++ b/client/shared/model/converse.ts @@ -52,6 +52,13 @@ export async function appendDMConverseMembers( return data; } +/** + * 退出多人会话 + */ +export async function leaveDMConverse(converseId: string): Promise { + await request.post('/api/chat/converse/leaveDMConverse', { converseId }); +} + /** * 获取会话信息 * @param converseId 会话ID diff --git a/client/shared/model/user.ts b/client/shared/model/user.ts index 47fcd54f..2c22f8ec 100644 --- a/client/shared/model/user.ts +++ b/client/shared/model/user.ts @@ -22,6 +22,11 @@ export interface UserLoginInfo extends UserBaseInfo { } export interface UserSettings { + /** + * 仅允许好友邀请创建或加入多人会话,默认关闭 + */ + onlyAllowFriendInvite?: boolean; + /** * 消息列表虚拟化 */ diff --git a/client/shared/redux/hooks/useConverseMessage.ts b/client/shared/redux/hooks/useConverseMessage.ts index 8d0778af..0a4bcac4 100644 --- a/client/shared/redux/hooks/useConverseMessage.ts +++ b/client/shared/redux/hooks/useConverseMessage.ts @@ -158,8 +158,7 @@ export function useConverseMessage(context: ConverseContext) { if (!isGroup) { // 如果是私信会话 // Step 1. 创建会话 并确保私信列表中存在该会话 - const converse = await ensureDMConverse(converseId, currentUserId); - dispatch(chatActions.setConverseInfo(converse)); + await ensureDMConverse(converseId, currentUserId); } else { // 如果是群组会话(文本频道) // Step 1. 确保群组会话存在 diff --git a/client/shared/redux/setup.ts b/client/shared/redux/setup.ts index 15b2c2ec..c8330047 100644 --- a/client/shared/redux/setup.ts +++ b/client/shared/redux/setup.ts @@ -19,6 +19,17 @@ import { sharedEvent } from '../event'; import type { InboxItem } from '../model/inbox'; import { useGlobalConfigStore } from '../store/globalConfig'; import type { GlobalConfig } from '../model/config'; +import { + refreshDMConverse, + removeDMConverseLocally, +} from '../helper/converse-helper'; +import { isCancelledError } from '@tanstack/react-query'; + +function handleConverseError(error: unknown) { + if (!isCancelledError(error)) { + console.error(error); + } +} /** * 初始化 Redux 上下文 @@ -87,10 +98,12 @@ function initial(socket: AppSocket, store: AppStore) { // TODO: 待优化, 可以在后端一次性返回 try { - const converse = await getCachedConverseInfo(converseId); - store.dispatch(chatActions.setConverseInfo(converse)); + const userId = store.getState().user.info?._id; + if (userId) { + await refreshDMConverse(converseId, userId, store); + } } catch (e) { - console.error(e); + handleConverseError(e); } }); }); @@ -130,9 +143,14 @@ function listenNotify(socket: AppSocket, store: AppStore) { } ); - socket.listen('chat.message.add', (message) => { + socket.listen('chat.message.add', async (message) => { // 处理接受到的消息 const converseId = message.converseId; + if (store.getState().chat.converseMembership[converseId]?.removed) { + return; + } + const version = + store.getState().chat.converseMembership[converseId]?.version ?? 0; const converse = store.getState().chat.converses[converseId]; // 添加消息到会话中 @@ -151,18 +169,38 @@ function listenNotify(socket: AppSocket, store: AppStore) { } else if (!message.groupId) { // 如果会话没有加载, 但是是私信消息 // 则获取会话信息后添加到会话消息中 - getCachedConverseInfo(converseId).then((converse) => { + try { + const converse = await getCachedConverseInfo(converseId, true); + const membership = store.getState().chat.converseMembership[converseId]; + const userId = store.getState().user.info?._id; + if ( + membership?.removed || + (membership?.version ?? 0) !== version || + !userId || + !converse?.members.includes(userId) + ) { + return; + } if ( [ChatConverseType.DM, ChatConverseType.Multi].includes(converse.type) ) { // 如果是私人会话, 则添加到dmlist - appendUserDMConverse(converse._id); + await appendUserDMConverse(converse._id); + } + if ( + (store.getState().chat.converseMembership[converseId]?.version ?? + 0) !== version + ) { + return; } store.dispatch(chatActions.setConverseInfo(converse)); appendMessage(); - }); + } catch (error) { + handleConverseError(error); + return; + } } else { // 是群组未加载的消息面板的消息 // 设置会话信息 @@ -230,10 +268,20 @@ function listenNotify(socket: AppSocket, store: AppStore) { socket.listen( 'chat.converse.updateDMConverse', (converse) => { - store.dispatch(chatActions.setConverseInfo(converse)); + const userId = store.getState().user.info?._id; + if (userId) { + refreshDMConverse(converse._id, userId, store).catch( + handleConverseError + ); + } } ); + socket.listen<{ converseId: string }>( + 'chat.converse.removeDMConverse', + ({ converseId }) => removeDMConverseLocally(converseId, store) + ); + socket.listen('group.add', (groupInfo) => { store.dispatch(groupActions.appendGroups([groupInfo])); }); diff --git a/client/shared/redux/slices/chat.ts b/client/shared/redux/slices/chat.ts index ca2b3e7a..8af70254 100644 --- a/client/shared/redux/slices/chat.ts +++ b/client/shared/redux/slices/chat.ts @@ -24,6 +24,8 @@ export interface ChatConverseState extends ChatConverseInfo { export interface ChatState { currentConverseId: string | null; // 当前活跃的会话id converses: Record; // <会话Id, 会话信息> + // 退出后阻止旧请求恢复会话;重新邀请需校验服务端成员信息和版本。 + converseMembership: Record; ack: Record; // <会话Id, 本地最后一条会话Id> inbox: InboxItem[]; @@ -37,6 +39,7 @@ export interface ChatState { const initialState: ChatState = { currentConverseId: null, converses: {}, + converseMembership: {}, ack: {}, inbox: [], lastMessageMap: {}, @@ -55,6 +58,9 @@ const chatSlice = createSlice({ */ setConverseInfo(state, action: PayloadAction) { const converseId = action.payload._id; + if (state.converseMembership[converseId]?.removed) { + return; + } const originInfo = state.converses[converseId] ? { ...state.converses[converseId] } @@ -220,6 +226,35 @@ const chatSlice = createSlice({ delete state.converses[converseId]; }, + removeDMConverse(state, action: PayloadAction<{ converseId: string }>) { + const { converseId } = action.payload; + state.converseMembership[converseId] = { + removed: true, + version: (state.converseMembership[converseId]?.version ?? 0) + 1, + }; + delete state.converses[converseId]; + delete state.ack[converseId]; + delete state.lastMessageMap[converseId]; + if (state.currentConverseId === converseId) { + state.currentConverseId = null; + } + }, + + restoreDMConverse( + state, + action: PayloadAction<{ converse: ChatConverseInfo; version: number }> + ) { + const { converse, version } = action.payload; + if ((state.converseMembership[converse._id]?.version ?? 0) !== version) { + return; + } + state.converseMembership[converse._id] = { removed: false, version }; + chatSlice.caseReducers.setConverseInfo( + state, + chatSlice.actions.setConverseInfo(converse) + ); + }, + /** * 清理所有会话信息 */ diff --git a/client/web/src/components/Panel/personal/ConversePanel.spec.tsx b/client/web/src/components/Panel/personal/ConversePanel.spec.tsx new file mode 100644 index 00000000..0bfbb86f --- /dev/null +++ b/client/web/src/components/Panel/personal/ConversePanel.spec.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { showAlert } from 'tailchat-shared'; +import { request } from 'tailchat-shared/api/request'; +import { ChatConverseType } from 'tailchat-shared/model/converse'; +import { appReducer, chatActions } from 'tailchat-shared/redux/slices'; +import { getReduxStore, ReduxProvider } from 'tailchat-shared/redux/store'; +import { ConversePanel } from './ConversePanel'; +import { queryClient } from 'tailchat-shared/cache'; +import { refreshDMConverse } from 'tailchat-shared/helper/converse-helper'; + +jest.mock('tailchat-shared/api/request', () => ({ + request: { get: jest.fn(), post: jest.fn() }, +})); +jest.mock('tailchat-shared', () => ({ + ...jest.requireActual('tailchat-shared/redux/hooks/useAppSelector'), + model: { converse: jest.requireActual('tailchat-shared/model/converse') }, + t: (text: string) => text, + useUserId: () => 'me', + useUserInfoList: () => [], + useDMConverseName: () => 'Conversation', + showAlert: jest.fn(), +})); +jest.mock('@/components/ChatBox', () => ({ + ChatBox: () =>
Messages
, +})); +jest.mock('@/components/ChatBox/ChatInputBox/context', () => ({ + ChatInputMentionsContextProvider: ({ children }: any) => children, +})); +jest.mock('@/components/IconBtn', () => ({ + IconBtn: ({ title, onClick }: any) => ( + + ), +})); +jest.mock('@/components/UserListItem', () => ({ UserListItem: () => null })); +jest.mock('@/components/Modal', () => ({ openModal: jest.fn() })); +jest.mock('@/components/modals/AppendDMConverseMembers', () => ({ + AppendDMConverseMembers: () => null, +})); +jest.mock('@/components/modals/CreateDMConverse', () => ({ + CreateDMConverse: () => null, +})); +jest.mock('@/components/OpenedPanelTip', () => ({ + OpenedPanelTip: () => null, +})); +jest.mock('@/hooks/usePanelWindow', () => ({ + usePanelWindow: () => ({ hasOpenedPanel: false }), +})); +jest.mock('@/plugin/common', () => ({ pluginPanelActions: [] })); +jest.mock('../common/MessageSearch', () => ({ + MessageSearchPanel: () => null, +})); +jest.mock('../common/Wrapper', () => ({ + CommonPanelWrapper: ({ children, actions }: any) => ( +
+ {actions({ setRightPanel: jest.fn() })} + {children} +
+ ), +})); + +function Location() { + return {useLocation().pathname}; +} + +describe('leaving a private conversation', () => { + const store = getReduxStore(); + + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + store.replaceReducer((state, action) => + appReducer(action.type === 'test/reset' ? undefined : state, action) + ); + store.dispatch({ type: 'test/reset' }); + (request.post as jest.Mock).mockResolvedValue({ data: {} }); + (request.get as jest.Mock).mockRejectedValue({ code: 403 }); + }); + + afterEach(() => queryClient.clear()); + + function open(type = ChatConverseType.Multi) { + store.dispatch( + chatActions.setConverseInfo({ + _id: 'conversation', + name: '', + type, + members: ['me', 'other'], + }) + ); + return render( + + + + } + /> + Friends} /> + + + + + ); + } + + test('a two-member Multi keeps invite, member list and leave actions', () => { + open(); + expect(screen.getByRole('button', { name: '邀请成员' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '成员列表' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '退出会话' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '创建会话' })).toBeNull(); + }); + + test('a direct message does not offer leaving', () => { + open(ChatConverseType.DM); + expect(screen.queryByRole('button', { name: '退出会话' })).toBeNull(); + expect(screen.getByRole('button', { name: '创建会话' })).toBeTruthy(); + }); + + test('leaving waits for confirmation and server success before removing or navigating', async () => { + open(); + fireEvent.click(screen.getByRole('button', { name: '退出会话' })); + expect(request.post).not.toHaveBeenCalled(); + expect(store.getState().chat.converses.conversation).toBeDefined(); + + const { onConfirm } = (showAlert as jest.Mock).mock.calls[0][0]; + (request.post as jest.Mock).mockRejectedValueOnce(new Error('failed')); + await act(async () => { + await expect(onConfirm()).rejects.toThrow('failed'); + }); + expect(store.getState().chat.converses.conversation).toBeDefined(); + expect(screen.getByRole('status').textContent).toBe( + '/main/personal/converse/conversation' + ); + + await act(async () => { + await onConfirm(); + }); + expect(request.post).toHaveBeenLastCalledWith( + '/api/chat/converse/leaveDMConverse', + { converseId: 'conversation' } + ); + expect(store.getState().chat.converses.conversation).toBeUndefined(); + expect(screen.getByRole('status').textContent).toBe( + '/main/personal/friends' + ); + expect(screen.queryByText('Messages')).toBeNull(); + }); + + test('a leave notification on another session closes the active conversation', async () => { + open(); + await act(async () => { + store.dispatch( + chatActions.removeDMConverse({ converseId: 'conversation' }) + ); + }); + expect(screen.getByRole('status').textContent).toBe( + '/main/personal/friends' + ); + expect(screen.queryByText('Messages')).toBeNull(); + }); + + test('a delayed leave response preserves a newer verified invitation', async () => { + let finishLeave!: (value: unknown) => void; + (request.post as jest.Mock).mockReturnValue( + new Promise((resolve) => { + finishLeave = resolve; + }) + ); + open(); + fireEvent.click(screen.getByRole('button', { name: '退出会话' })); + const leaving = (showAlert as jest.Mock).mock.calls[0][0].onConfirm(); + (request.get as jest.Mock).mockResolvedValue({ + data: { + _id: 'conversation', + name: '', + type: ChatConverseType.Multi, + members: ['me', 'other'], + }, + }); + await act(async () => { + await refreshDMConverse('conversation', 'me'); + }); + await act(async () => { + finishLeave({ data: true }); + await leaving; + }); + + expect(store.getState().chat.converses.conversation).toBeDefined(); + expect(screen.getByRole('status').textContent).toBe( + '/main/personal/converse/conversation' + ); + }); +}); diff --git a/client/web/src/components/Panel/personal/ConversePanel.tsx b/client/web/src/components/Panel/personal/ConversePanel.tsx index ca8b6728..3c08d70e 100644 --- a/client/web/src/components/Panel/personal/ConversePanel.tsx +++ b/client/web/src/components/Panel/personal/ConversePanel.tsx @@ -3,6 +3,8 @@ import { UserListItem } from '@/components/UserListItem'; import React from 'react'; import { ChatConverseState, + model, + showAlert, t, useAppSelector, useDMConverseName, @@ -20,6 +22,8 @@ import { DMPluginPanelActionProps, pluginPanelActions } from '@/plugin/common'; import { CreateDMConverse } from '@/components/modals/CreateDMConverse'; import { MessageSearchPanel } from '../common/MessageSearch'; import { ChatInputMentionsContextProvider } from '@/components/ChatBox/ChatInputBox/context'; +import { Navigate } from 'react-router'; +import { refreshDMConverse } from 'tailchat-shared/helper/converse-helper'; const ConversePanelTitle: React.FC<{ converse: ChatConverseState }> = React.memo(({ converse }) => { @@ -50,6 +54,9 @@ export const ConversePanel: React.FC = React.memo( const converse = useAppSelector( (state) => state.chat.converses[converseId] ); + const removed = useAppSelector( + (state) => state.chat.converseMembership[converseId]?.removed + ); const userId = useUserId(); const userInfos = useUserInfoList( (converse?.members ?? []).filter((m) => m !== userId) @@ -57,6 +64,9 @@ export const ConversePanel: React.FC = React.memo( const { hasOpenedPanel, openPanelWindow, closePanelWindow } = usePanelWindow(`/panel/personal/converse/${converseId}`); + if (removed) { + return ; + } if (hasOpenedPanel) { return ; } @@ -97,7 +107,7 @@ export const ConversePanel: React.FC = React.memo( iconClassName="text-2xl" onClick={openPanelWindow} />, - converse.members.length === 2 ? ( + converse.type === model.converse.ChatConverseType.DM ? ( = React.memo( }) } />, - // 当成员数大于2时,显示成员列表按钮 - converse.members.length > 2 && ( + converse.type === model.converse.ChatConverseType.Multi && ( = React.memo( } /> ), + converse.type === model.converse.ChatConverseType.Multi && ( + + showAlert({ + message: t( + '确定要退出此多人会话么?退出后将不再接收此会话的消息' + ), + onConfirm: async () => { + await model.converse.leaveDMConverse(converseId); + if (userId) { + await refreshDMConverse(converseId, userId); + } + }, + }) + } + /> + ), ]); }} > diff --git a/client/web/src/components/modals/AppendDMConverseMembers.spec.tsx b/client/web/src/components/modals/AppendDMConverseMembers.spec.tsx new file mode 100644 index 00000000..cd3b830e --- /dev/null +++ b/client/web/src/components/modals/AppendDMConverseMembers.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { request } from 'tailchat-shared/api/request'; +import { setToasts } from 'tailchat-shared/manager/ui'; +import { closeModal } from '../Modal'; +import { AppendDMConverseMembers } from './AppendDMConverseMembers'; + +jest.mock('tailchat-shared/api/request', () => ({ + request: { post: jest.fn() }, +})); +jest.mock('tailchat-shared', () => ({ + ...jest.requireActual('tailchat-shared/model/converse'), + ...jest.requireActual('tailchat-shared/hooks/useAsyncFn'), + ...jest.requireActual('tailchat-shared/hooks/useAsyncRequest'), + t: (text: string) => text, +})); +jest.mock('../Modal', () => ({ + closeModal: jest.fn(), + ModalWrapper: ({ children }: any) => children, +})); +jest.mock('../UserPicker/FriendPicker', () => ({ + FriendPicker: ({ onChange }: any) => ( + + ), +})); + +test('an invitation rejected by the recipient setting shows the server error and keeps the dialog open', async () => { + const toast = jest.fn(); + setToasts(toast); + (request.post as jest.Mock).mockRejectedValue( + new Error('Only friends may invite this user') + ); + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Select' })); + fireEvent.click(screen.getByRole('button', { name: /确\s*认/ })); + + await waitFor(() => + expect(toast).toHaveBeenCalledWith( + 'Only friends may invite this user', + 'error' + ) + ); + expect(closeModal).not.toHaveBeenCalled(); + errorLog.mockRestore(); +}); diff --git a/client/web/src/components/modals/AppendDMConverseMembers.tsx b/client/web/src/components/modals/AppendDMConverseMembers.tsx index 7474b215..2a0f9ee7 100644 --- a/client/web/src/components/modals/AppendDMConverseMembers.tsx +++ b/client/web/src/components/modals/AppendDMConverseMembers.tsx @@ -1,6 +1,6 @@ import { Button } from 'antd'; import React, { useState } from 'react'; -import { appendDMConverseMembers, t, useAsyncFn } from 'tailchat-shared'; +import { appendDMConverseMembers, t, useAsyncRequest } from 'tailchat-shared'; import { FriendPicker } from '../UserPicker/FriendPicker'; import { closeModal, ModalWrapper } from '../Modal'; @@ -17,7 +17,7 @@ export const AppendDMConverseMembers: React.FC = const { converseId, withoutUserIds = [] } = props; const [selectedFriendIds, setSelectedFriendIds] = useState([]); - const [{ loading }, handleConfirm] = useAsyncFn(async () => { + const [{ loading }, handleConfirm] = useAsyncRequest(async () => { await appendDMConverseMembers(converseId, [...selectedFriendIds]); closeModal(); }, [converseId, selectedFriendIds]); diff --git a/client/web/src/components/modals/SettingsView/System.tsx b/client/web/src/components/modals/SettingsView/System.tsx index d296a5be..80c4f0c6 100644 --- a/client/web/src/components/modals/SettingsView/System.tsx +++ b/client/web/src/components/modals/SettingsView/System.tsx @@ -56,6 +56,22 @@ export const SettingsSystem: React.FC = React.memo(() => { } /> + + setSettings({ onlyAllowFriendInvite: checked }) + } + /> + } + /> + {pluginSettings .filter((item) => item.position === 'system') .map((item) => { diff --git a/client/web/src/routes/Main/Content/Personal/SidebarDMItem.tsx b/client/web/src/routes/Main/Content/Personal/SidebarDMItem.tsx index d84c7364..cb1755b8 100644 --- a/client/web/src/routes/Main/Content/Personal/SidebarDMItem.tsx +++ b/client/web/src/routes/Main/Content/Personal/SidebarDMItem.tsx @@ -3,6 +3,7 @@ import { ChatConverseState, getCachedUserInfo, model, + t, useAppDispatch, useAsync, useAsyncRequest, @@ -38,6 +39,10 @@ export const SidebarDMItem: React.FC = React.memo( ) ); + if (userInfos.length === 0) { + return ; + } + return ( ({ @@ -58,14 +63,16 @@ export const SidebarDMItem: React.FC = React.memo( key={converseId} name={name} action={ - { - e.stopPropagation(); - e.preventDefault(); - handleRemove(); - }} - /> + + { + e.stopPropagation(); + e.preventDefault(); + handleRemove(); + }} + /> + } icon={icon} to={`/main/personal/converse/${converseId}`} diff --git a/client/web/test/converse-membership.spec.ts b/client/web/test/converse-membership.spec.ts new file mode 100644 index 00000000..a4669d4d --- /dev/null +++ b/client/web/test/converse-membership.spec.ts @@ -0,0 +1,258 @@ +import { request } from 'tailchat-shared/api/request'; +import type { AppSocket } from 'tailchat-shared/api/socket'; +import { queryClient } from 'tailchat-shared/cache'; +import { CacheKey } from 'tailchat-shared/cache/cache'; +import { sharedEvent } from 'tailchat-shared/event'; +import { + ensureDMConverse, + refreshDMConverse, +} from 'tailchat-shared/helper/converse-helper'; +import { ChatConverseType } from 'tailchat-shared/model/converse'; +import { setupRedux } from 'tailchat-shared/redux/setup'; +import { + appReducer, + chatActions, + userActions, +} from 'tailchat-shared/redux/slices'; +import * as storeModule from 'tailchat-shared/redux/store'; + +jest.mock('tailchat-shared/api/request', () => ({ + request: { get: jest.fn(), post: jest.fn() }, +})); + +const converse = { + _id: 'conversation', + type: ChatConverseType.Multi, + name: '', + members: ['me', 'other'], +}; +const message = { + _id: 'message', + converseId: converse._id, + author: 'other', + content: 'hello', +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe('private conversation membership synchronization', () => { + let store: storeModule.AppStore; + let listeners: Record unknown>; + let receiveMessage: jest.Mock; + let socketRequest: jest.Mock; + let reconnect: () => void; + + beforeEach(async () => { + queryClient.clear(); + jest.clearAllMocks(); + store = storeModule.getReduxStore(); + store.replaceReducer((state, action) => + appReducer(action.type === 'test/reset' ? undefined : state, action) + ); + store.dispatch({ type: 'test/reset' }); + store.dispatch(userActions.setUserInfo({ _id: 'me' } as any)); + listeners = {}; + receiveMessage = jest.fn(); + sharedEvent.on('receiveMessage', receiveMessage); + (request.get as jest.Mock).mockResolvedValue({ data: converse }); + (request.post as jest.Mock).mockResolvedValue({ data: {} }); + socketRequest = jest.fn().mockResolvedValue([]); + setupRedux( + { + request: socketRequest, + listen: (name: string, listener: (data: any) => unknown) => { + listeners[name] = listener; + }, + onReconnect: (callback: () => void) => { + reconnect = callback; + }, + } as unknown as AppSocket, + store + ); + await Promise.resolve(); + }); + + afterEach(() => { + sharedEvent.off('receiveMessage', receiveMessage); + queryClient.clear(); + jest.restoreAllMocks(); + }); + + async function flush() { + // Drain query and socket continuations without advancing query retry timers. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + test('leaving clears the conversation, unread state and cache, and suppresses late messages', async () => { + store.dispatch(chatActions.setConverseInfo(converse)); + store.dispatch(chatActions.updateCurrentConverseId(converse._id)); + store.dispatch( + chatActions.setConverseAck({ + converseId: converse._id, + lastMessageId: 'old', + }) + ); + store.dispatch( + chatActions.setLastMessageMap([ + { converseId: converse._id, lastMessageId: 'new' }, + ]) + ); + queryClient.setQueryData([CacheKey.converse, converse._id], converse); + + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + listeners['chat.message.add'](message); + await flush(); + + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + expect(store.getState().chat.currentConverseId).toBeNull(); + expect(store.getState().chat.ack[converse._id]).toBeUndefined(); + expect(store.getState().chat.lastMessageMap[converse._id]).toBeUndefined(); + expect( + queryClient.getQueryData([CacheKey.converse, converse._id]) + ).toBeUndefined(); + expect(request.post).not.toHaveBeenCalled(); + expect(receiveMessage).not.toHaveBeenCalled(); + }); + + test('an in-flight message lookup cannot restore membership after leaving', async () => { + const response = deferred<{ data: typeof converse }>(); + (request.get as jest.Mock).mockReturnValue(response.promise); + listeners['chat.message.add'](message); + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + response.resolve({ data: converse }); + await flush(); + + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + expect(request.post).not.toHaveBeenCalled(); + expect(receiveMessage).not.toHaveBeenCalled(); + }); + + test('reopening a cached conversation checks current membership before adding it to the list', async () => { + queryClient.setQueryData([CacheKey.converse, converse._id], converse); + (request.get as jest.Mock).mockResolvedValue({ + data: { ...converse, members: ['other'] }, + }); + + await expect(ensureDMConverse(converse._id, 'me')).rejects.toThrow(); + + expect(request.get).toHaveBeenCalled(); + expect(request.post).not.toHaveBeenCalled(); + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + }); + + test('a stale update cannot restore membership but a verified new invitation can', async () => { + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + (request.get as jest.Mock).mockResolvedValue({ + data: { ...converse, members: ['other'] }, + }); + listeners['chat.converse.updateDMConverse'](converse); + await flush(); + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + + (request.get as jest.Mock).mockResolvedValue({ data: converse }); + listeners['chat.converse.updateDMConverse'](converse); + await flush(); + expect(store.getState().chat.converses[converse._id]?.members).toEqual([ + 'me', + 'other', + ]); + }); + + test('leaving while an invitation refresh is pending wins over the old response', async () => { + const response = deferred<{ data: typeof converse }>(); + (request.get as jest.Mock).mockReturnValue(response.promise); + listeners['chat.converse.updateDMConverse'](converse); + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + response.resolve({ data: converse }); + await flush(); + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + }); + + test('closing only hides the conversation and a later message can reopen it', async () => { + store.dispatch(chatActions.setConverseInfo(converse)); + store.dispatch(chatActions.removeConverse({ converseId: converse._id })); + listeners['chat.message.add'](message); + await flush(); + expect(store.getState().chat.converses[converse._id]?.messages).toEqual([ + message, + ]); + expect(receiveMessage).toHaveBeenCalledWith(message); + }); + + test('a list membership lookup started before leaving cannot restore the conversation', async () => { + const response = deferred<{ data: typeof converse }>(); + (request.get as jest.Mock).mockReturnValue(response.promise); + socketRequest.mockImplementation(async (name) => + name === 'user.dmlist.getAllConverse' ? [converse._id] : [] + ); + reconnect(); + await flush(); + expect(request.get).toHaveBeenCalled(); + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + response.resolve({ data: converse }); + await flush(); + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + }); + + test('reconnect restores an invitation received while offline', async () => { + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + socketRequest.mockImplementation(async (name) => + name === 'user.dmlist.getAllConverse' ? [converse._id] : [] + ); + reconnect(); + await flush(); + expect(store.getState().chat.converses[converse._id]?.members).toEqual( + converse.members + ); + expect(store.getState().chat.converseMembership[converse._id].removed).toBe( + false + ); + }); + + test('a pending list-add response cannot restore or notify after leaving', async () => { + const response = deferred<{ data: unknown }>(); + (request.post as jest.Mock).mockReturnValue(response.promise); + listeners['chat.message.add'](message); + await flush(); + expect(request.post).toHaveBeenCalled(); + listeners['chat.converse.removeDMConverse']({ converseId: converse._id }); + response.resolve({ data: {} }); + await flush(); + expect(store.getState().chat.converses[converse._id]).toBeUndefined(); + expect(receiveMessage).not.toHaveBeenCalled(); + }); + + test('opening a conversation and a concurrent invitation refresh both complete', async () => { + const response = deferred<{ data: typeof converse }>(); + (request.get as jest.Mock) + .mockReturnValueOnce(response.promise) + .mockResolvedValue({ data: converse }); + const opening = ensureDMConverse(converse._id, 'me'); + const updated = refreshDMConverse(converse._id, 'me'); + response.resolve({ data: converse }); + await expect(updated).resolves.toEqual(converse); + await expect(opening).resolves.toEqual(converse); + }); + + test('an older denied membership response cannot erase a newer invitation', async () => { + const response = deferred<{ data: typeof converse }>(); + (request.get as jest.Mock) + .mockReturnValueOnce(response.promise) + .mockResolvedValue({ data: converse }); + const oldRefresh = refreshDMConverse(converse._id, 'me'); + await refreshDMConverse(converse._id, 'me'); + response.reject({ code: 403 }); + await oldRefresh; + expect(store.getState().chat.converses[converse._id]?.members).toEqual( + converse.members + ); + }); +}); diff --git a/client/web/test/user-settings.spec.tsx b/client/web/test/user-settings.spec.tsx new file mode 100644 index 00000000..38086fab --- /dev/null +++ b/client/web/test/user-settings.spec.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import { request } from 'tailchat-shared/api/request'; +import { queryClient } from 'tailchat-shared/cache'; +import { CacheKey } from 'tailchat-shared/cache/cache'; +import { CacheProvider } from 'tailchat-shared/cache/Provider'; +import { useUserSettings } from 'tailchat-shared/hooks/model/useUserSettings'; +import { setStorage } from 'tailchat-shared/manager/storage'; +import { setToasts } from 'tailchat-shared/manager/ui'; +import { SettingsSystem } from '../src/components/modals/SettingsView/System'; + +jest.mock('tailchat-shared/api/request', () => ({ + request: { get: jest.fn(), post: jest.fn() }, +})); +jest.mock('tailchat-shared', () => ({ + ...jest.requireActual('tailchat-shared/hooks/model/useUserSettings'), + t: (text: string) => text, + useColorScheme: () => ({ colorScheme: 'dark', setColorScheme: jest.fn() }), + useAlphaMode: () => ({ isAlphaMode: false, setAlphaMode: jest.fn() }), +})); +jest.mock('@/components/FullModal/Factory', () => ({ + FullModalFactory: () => null, +})); +jest.mock('@/components/FullModal/Field', () => ({ + FullModalField: ({ title, content, tip }: any) => ( +
+ {title} + {content} + {tip} +
+ ), +})); +jest.mock('@/components/LanguageSelect', () => ({ + LanguageSelect: () => null, +})); +jest.mock('@/plugin/common', () => ({ + pluginColorScheme: [], + pluginSettings: [], +})); + +beforeEach(() => { + setStorage(() => ({ + get: async () => undefined, + set: async () => {}, + save: async () => {}, + remove: async () => {}, + })); + setToasts(jest.fn()); + queryClient.clear(); + jest.clearAllMocks(); +}); + +afterEach(() => { + queryClient.clear(); + jest.restoreAllMocks(); +}); + +test('a failed privacy setting save restores the last saved value', async () => { + queryClient.setQueryData([CacheKey.userSettings], { + onlyAllowFriendInvite: false, + }); + (request.post as jest.Mock).mockRejectedValueOnce(new Error('save failed')); + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {}); + const { result, unmount } = renderHook(() => useUserSettings(), { + wrapper: CacheProvider, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.setSettings({ onlyAllowFriendInvite: true }); + }); + + expect(queryClient.getQueryData([CacheKey.userSettings])).toEqual({ + onlyAllowFriendInvite: false, + }); + await waitFor(() => + expect(result.current.settings.onlyAllowFriendInvite).toBe(false) + ); + unmount(); + errorLog.mockRestore(); + queryClient.clear(); +}); + +test('the invitation preference defaults off and persists both switch values', async () => { + queryClient.setQueryData([CacheKey.userSettings], {}); + (request.post as jest.Mock) + .mockResolvedValueOnce({ data: { onlyAllowFriendInvite: true } }) + .mockResolvedValueOnce({ data: { onlyAllowFriendInvite: false } }); + render( + + + + ); + const toggle = screen.getByRole('switch', { name: '仅允许好友邀请创建会话' }); + await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false)); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + + fireEvent.click(toggle); + await waitFor(() => expect(toggle.getAttribute('aria-checked')).toBe('true')); + expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', { + settings: { onlyAllowFriendInvite: true }, + }); + await waitFor(() => expect(toggle.hasAttribute('disabled')).toBe(false)); + + fireEvent.click(toggle); + await waitFor(() => + expect(toggle.getAttribute('aria-checked')).toBe('false') + ); + expect(request.post).toHaveBeenLastCalledWith('/api/user/setUserSettings', { + settings: { onlyAllowFriendInvite: false }, + }); +}); diff --git a/server/locales/en-US/translation.json b/server/locales/en-US/translation.json index faf7face..1dca88b7 100644 --- a/server/locales/en-US/translation.json +++ b/server/locales/en-US/translation.json @@ -69,6 +69,7 @@ "ke050bc7a": "Username does not exist", "ke0d53ced": "The other party is already your friend and cannot be added again", "ke19c80a5": "User has no upload permission", + "ke414da82": "This user only allows friends to invite them to multi-person conversations", "ke5849544": "Wrong password", "ke82b4383": "You cannot send messages because you are banned", "ke99cd649": "No access to converse information permission", diff --git a/server/locales/zh-CN/translation.json b/server/locales/zh-CN/translation.json index 1f25e645..63b87a96 100644 --- a/server/locales/zh-CN/translation.json +++ b/server/locales/zh-CN/translation.json @@ -69,6 +69,7 @@ "ke050bc7a": "用户名不存在", "ke0d53ced": "对方已经是您的好友, 不能再次添加", "ke19c80a5": "用户无上传权限", + "ke414da82": "对方仅允许好友邀请加入多人会话", "ke5849544": "密码错误", "ke82b4383": "您因为被禁言无法发送消息", "ke99cd649": "没有获取会话信息权限", diff --git a/server/models/chat/converse.ts b/server/models/chat/converse.ts index ced0b6eb..d1fc8718 100644 --- a/server/models/chat/converse.ts +++ b/server/models/chat/converse.ts @@ -57,6 +57,7 @@ export class Converse extends TimeStamps implements Base { members: string[] ): Promise | null> { const converse = await this.findOne({ + type: 'DM', members: { $all: [...members], $size: members.length, diff --git a/server/models/user/user.ts b/server/models/user/user.ts index e5d61603..1b1a10cf 100644 --- a/server/models/user/user.ts +++ b/server/models/user/user.ts @@ -21,6 +21,8 @@ export interface UserSettings { * 消息列表虚拟化 */ messageListVirtualization?: boolean; + /** Only friends may invite this user to a multi conversation; off by default. */ + onlyAllowFriendInvite?: boolean; [key: string]: any; } diff --git a/server/openapi.json b/server/openapi.json index 140c148f..cbf0e20b 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -559,7 +559,8 @@ "type": "string" }, "memberIds": { - "type": "array" + "type": "array", + "items": "string" } } } @@ -3395,6 +3396,24 @@ } } } + }, + "/chat.converse/leaveDMConverse": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "converseId": { + "type": "string" + } + } + } + } + } + } + } } } } diff --git a/server/services/core/chat/converse.service.ts b/server/services/core/chat/converse.service.ts index 62da9cb9..6f2a96f3 100644 --- a/server/services/core/chat/converse.service.ts +++ b/server/services/core/chat/converse.service.ts @@ -14,6 +14,7 @@ import type { ConverseDocument, ConverseModel, } from '../../../models/chat/converse'; +import type { UserSettings } from '../../../models/user/user'; interface ConverseService extends TcService, @@ -40,15 +41,28 @@ class ConverseService extends TcService { { params: { converseId: 'string', - memberIds: 'array', + memberIds: { type: 'array', items: 'string' }, }, } ); + this.registerAction('leaveDMConverse', this.leaveDMConverse, { + params: { converseId: 'string' }, + }); this.registerAction('findConverseInfo', this.findConverseInfo, { params: { converseId: 'string', }, }); + this.registerAction( + 'syncConverseMember', + (ctx: TcContext<{ converseId: string }>) => + this.syncConverseMember(ctx, ctx.params.converseId, ctx.meta.userId), + { + visibility: 'public', + disableSocket: true, + params: { converseId: 'string' }, + } + ); this.registerAction('findAndJoinRoom', this.findAndJoinRoom); } @@ -80,6 +94,7 @@ class ConverseService extends TcService { if (participantList.length > 2) { // 多人会话 + await this.checkInvitationPreferences(ctx, participantList); converse = await this.adapter.model.create({ type: 'Multi', members: participantList.map((id) => new Types.ObjectId(id)), @@ -89,37 +104,10 @@ class ConverseService extends TcService { const roomId = String(converse._id); await Promise.all( participantList.map((memberId) => - call(ctx).joinSocketIORoom([roomId], memberId) + this.syncConverseMember(ctx, roomId, memberId) ) ); - // 广播更新消息 - await this.roomcastNotify( - ctx, - roomId, - 'updateDMConverse', - converse.toJSON() - ); - - // 更新dmlist 异步处理 - Promise.all( - participantList.map(async (memberId) => { - try { - await ctx.call( - 'user.dmlist.addConverse', - { converseId: roomId }, - { - meta: { - userId: memberId, - }, - } - ); - } catch (e) { - this.logger.error(e); - } - }) - ); - if (participantList.length > 2) { // 如果创建的是一个多人会话(非双人), 发送系统消息 await Promise.all( @@ -147,52 +135,52 @@ class ConverseService extends TcService { ctx: TcContext<{ converseId: string; memberIds: string[] }> ) { const userId = ctx.meta.userId; - const { converseId, memberIds } = ctx.params; - - const converse = await this.adapter.model.findById(converseId); - if (!converse) { + const { converseId } = ctx.params; + const current = await this.adapter.model.findById(converseId); + if (!current) { throw new DataNotFoundError(); } - - if (!converse.members.map(String).includes(userId)) { - throw new Error('不是会话参与者, 无法添加成员'); + if ( + current.type !== 'Multi' || + !current.members.map(String).includes(userId) + ) { + throw new NoPermissionError(ctx.meta.t('没有当前会话权限')); } - converse.members.push(...memberIds.map((uid) => new Types.ObjectId(uid))); - await converse.save(); + const requestedMemberIds = _.uniq(ctx.params.memberIds); + const memberIds = _.difference( + requestedMemberIds, + current.members.map(String) + ); + await this.checkInvitationPreferences(ctx, memberIds); + const converse = await this.adapter.model.findOneAndUpdate( + { _id: converseId, type: 'Multi', members: new Types.ObjectId(userId) }, + { + $addToSet: { + members: { $each: memberIds.map((id) => new Types.ObjectId(id)) }, + }, + }, + { new: true } + ); + if (!converse) { + throw new NoPermissionError(ctx.meta.t('没有当前会话权限')); + } await Promise.all( - memberIds.map((uid) => - call(ctx).joinSocketIORoom([String(converseId)], uid) + requestedMemberIds.map((id) => + this.syncConverseMember(ctx, converseId, id) ) ); - - // 广播更新会话列表 await this.roomcastNotify( ctx, converseId, 'updateDMConverse', - converse.toJSON() + (await this.adapter.model.findById(converseId)).toJSON() ); - // 更新dmlist 异步处理 - Promise.all( - memberIds.map(async (memberId) => { - try { - await ctx.call( - 'user.dmlist.addConverse', - { converseId }, - { - meta: { - userId: memberId, - }, - } - ); - } catch (e) { - this.logger.error(e); - } - }) - ); + if (memberIds.length === 0) { + return converse; + } // 发送系统消息, 异步处理 await Promise.all( @@ -211,6 +199,120 @@ class ConverseService extends TcService { return converse; } + async leaveDMConverse(ctx: TcContext<{ converseId: string }>) { + const { converseId } = ctx.params; + const { userId, t } = ctx.meta; + const memberId = new Types.ObjectId(userId); + const converse = await this.adapter.model.findOneAndUpdate( + { _id: converseId, type: 'Multi', members: memberId }, + { $pull: { members: memberId } }, + { new: true } + ); + if (!converse) { + // 已经不是成员(重试或从未加入): 只清理本人的房间和列表, 不通知其他成员 + const current = await this.adapter.model.findById(converseId); + if (current?.type !== 'Multi') { + throw new NoPermissionError(t('没有当前会话权限')); + } + await this.syncConverseMember(ctx, converseId, userId); + return true; + } + + await this.syncConverseMember(ctx, converseId, userId); + await this.roomcastNotify( + ctx, + converseId, + 'updateDMConverse', + converse.toJSON() + ); + return true; + } + + private async checkInvitationPreferences( + ctx: TcContext, + memberIds: string[] + ) { + const inviterId = ctx.meta.userId; + await Promise.all( + _.without(_.uniq(memberIds), inviterId).map(async (memberId) => { + const settings = await this.broker.call( + 'user.getUserSettings', + {}, + { + meta: { ...ctx.meta, userId: memberId }, + } + ); + if (settings?.onlyAllowFriendInvite === true) { + const isFriend = await this.broker.call( + 'friend.checkIsFriend', + { targetId: inviterId }, + { + meta: { ...ctx.meta, userId: memberId }, + } + ); + if (!isFriend) { + throw new NoPermissionError( + ctx.meta.t('对方仅允许好友邀请加入多人会话') + ); + } + } + }) + ); + } + + /** Recheck after side effects so a delayed join or leave follows current membership. */ + private async syncConverseMember( + ctx: TcContext, + converseId: string, + userId: string + ) { + // ponytail: 每轮处理一次并发的退出/重邀, 超过上限视为异常并失败关闭 + for (let pass = 0; pass < 5; pass++) { + const converse = await this.adapter.model.findById(converseId); + const isMember = converse?.members.map(String).includes(userId) ?? false; + if (isMember) { + await call(ctx).joinSocketIORoom([converseId], userId); + try { + await this.broker.call( + 'user.dmlist.addConverse', + { converseId }, + { meta: { ...ctx.meta, userId } } + ); + } catch (error) { + if (error.code === 403) { + continue; + } + throw error; + } + await this.unicastNotify( + ctx, + userId, + 'updateDMConverse', + converse.toJSON() + ); + } else { + await call(ctx).leaveSocketIORoom([converseId], userId); + await this.broker.call( + 'user.dmlist.removeConverse', + { converseId }, + { meta: { ...ctx.meta, userId } } + ); + await this.unicastNotify(ctx, userId, 'removeDMConverse', { + converseId, + }); + } + const latest = await this.adapter.model.findById(converseId); + if ( + (latest?.members.map(String).includes(userId) ?? false) === isMember + ) { + return isMember; + } + } + throw new Error( + `Converse ${converseId} membership of ${userId} did not settle` + ); + } + /** * 查找会话 */ @@ -224,6 +326,9 @@ class ConverseService extends TcService { const t = ctx.meta.t; const converse = await this.adapter.findById(converseId); + if (!converse) { + throw new DataNotFoundError(); + } if (userId !== SYSTEM_USERID) { // not system, check permission @@ -242,7 +347,7 @@ class ConverseService extends TcService { */ async findAndJoinRoom(ctx: TcContext) { const userId = ctx.meta.userId; - const dmConverseIds = await this.adapter.model.findAllJoinedConverseId( + let dmConverseIds = await this.adapter.model.findAllJoinedConverseId( userId ); @@ -261,6 +366,14 @@ class ConverseService extends TcService { ...subscribeFeaturePanelIds, ]); + const currentIds = await this.adapter.model.findAllJoinedConverseId(userId); + await Promise.all( + _.difference(dmConverseIds, currentIds).map((id) => + this.syncConverseMember(ctx, id, userId) + ) + ); + dmConverseIds = currentIds; + return { dmConverseIds, groupIds, diff --git a/server/services/core/chat/message.service.ts b/server/services/core/chat/message.service.ts index 41dea1b8..717acda1 100644 --- a/server/services/core/chat/message.service.ts +++ b/server/services/core/chat/message.service.ts @@ -13,7 +13,6 @@ import { NoPermissionError, call, PERMISSION, - NotFoundError, SYSTEM_USERID, RateLimitError, getGroupPanelSlowMode, @@ -148,6 +147,7 @@ class MessageService extends TcService { }> ) { const { converseId, startId } = ctx.params; + await this.checkConversePermission(ctx, converseId); const docs = await this.adapter.model.fetchConverseMessage( converseId, startId ?? null @@ -337,28 +337,30 @@ class MessageService extends TcService { if (converseInfo) { const converseMemberIds = converseInfo.members.map((m) => String(m)); + // 通知失败不能影响已经持久化的消息, 因此不等待也不抛出 call(ctx) .isUserOnline(converseMemberIds) - .then((onlineList) => { - _.zip(converseMemberIds, onlineList).forEach( - ([memberId, isOnline]) => { - if (isOnline) { - // 用户在线,则直接推送,通过客户端来创建会话 - this.unicastNotify(ctx, memberId, 'add', json); - } else { - // 用户离线,确保追加到会话中 - ctx.call( - 'user.dmlist.addConverse', - { converseId }, - { - meta: { - userId: memberId, - }, - } - ); + .then((onlineList) => + Promise.all( + _.zip(converseMemberIds, onlineList).map( + async ([memberId, isOnline]) => { + if (isOnline) { + // 用户在线,则直接推送,通过客户端来创建会话 + await this.unicastNotify(ctx, memberId, 'add', json); + } else { + // 用户离线,确保追加到会话中 + await this.broker.call( + 'user.dmlist.addConverse', + { converseId }, + { meta: { ...ctx.meta, userId: memberId } } + ); + } } - } - ); + ) + ) + ) + .catch((error) => { + this.logger.error(error); }); } } @@ -610,6 +612,8 @@ class MessageService extends TcService { if (!groupInfo.members.map((m) => m.userId).includes(userId)) { throw new Error(t('不是群组成员无法搜索消息')); } + } else { + await this.checkConversePermission(ctx, converseId); } const messages = this.adapter.model @@ -680,6 +684,12 @@ class MessageService extends TcService { const userId = ctx.meta.userId; const message = await this.adapter.model.findById(messageId); + if (!message) { + throw new DataNotFoundError(); + } + if (!message.groupId) { + await this.checkConversePermission(ctx, String(message.converseId)); + } const appendReaction = { name: emoji, @@ -721,6 +731,12 @@ class MessageService extends TcService { const userId = ctx.meta.userId; const message = await this.adapter.model.findById(messageId); + if (!message) { + throw new DataNotFoundError(); + } + if (!message.groupId) { + await this.checkConversePermission(ctx, String(message.converseId)); + } const removedReaction = { name: emoji, @@ -774,31 +790,41 @@ class MessageService extends TcService { // 鉴权是否能获取到会话内容 if (groupId) { - // 是群组 + // 是群组: 必须是群组成员, 且会话必须是该群组的面板, 防止用无关群组冒充私信会话 const group = await call(ctx).getGroupInfo(groupId); - if (group.members.findIndex((m) => String(m.userId) === userId) === -1) { - // 不存在该用户 + if ( + !group.members.some((m) => String(m.userId) === userId) || + !group.panels.some((panel) => String(panel.id) === converseId) + ) { throw new NoPermissionError(t('没有当前会话权限')); } - } else { - // 是普通会话 - const converse = await ctx.call< - any, - { - converseId: string; - } - >('chat.converse.findConverseInfo', { - converseId, - }); + return { bypassSlowMode: false }; + } - if (!converse) { - throw new NotFoundError(t('没有找到会话信息')); - } - const memebers = converse.members ?? []; - if (memebers.findIndex((member) => String(member) === userId) === -1) { - throw new NoPermissionError(t('没有当前会话权限')); + // 是普通会话: 非成员由 findConverseInfo 拒绝(403) + let converse; + try { + converse = await ctx.call( + 'chat.converse.findConverseInfo', + { converseId } + ); + } catch (error) { + if (error.code !== 404) { + throw error; } } + if (converse) { + return { bypassSlowMode: false }; + } + + // 群组面板没有会话记录(404), 按已加入的群组面板校验 + const { textPanelIds, subscribeFeaturePanelIds } = await ctx.call<{ + textPanelIds: string[]; + subscribeFeaturePanelIds: string[]; + }>('group.getJoinedGroupAndPanelIds'); + if (![...textPanelIds, ...subscribeFeaturePanelIds].includes(converseId)) { + throw new NoPermissionError(t('没有当前会话权限')); + } return { bypassSlowMode: false }; } diff --git a/server/services/core/user/dmlist.service.ts b/server/services/core/user/dmlist.service.ts index 6b23c74b..74e1f94a 100644 --- a/server/services/core/user/dmlist.service.ts +++ b/server/services/core/user/dmlist.service.ts @@ -5,7 +5,13 @@ import type { UserDMListDocument, UserDMListModel, } from '../../../models/user/dmlist'; -import { TcService, TcContext, TcDbService, db } from 'tailchat-server-sdk'; +import { + TcService, + TcContext, + TcDbService, + db, + call, +} from 'tailchat-server-sdk'; interface UserDMListService extends TcService, @@ -34,6 +40,8 @@ class UserDMListService extends TcService { const userId = ctx.meta.userId; const converseId = ctx.params.converseId; + await call(ctx).getConverseInfo(converseId); + const record = await this.adapter.model.findOrCreate({ userId, }); @@ -44,6 +52,16 @@ class UserDMListService extends TcService { }, }); + try { + await call(ctx).getConverseInfo(converseId); + } catch (error) { + if (error.code === 403) { + // Reconcile against current membership: a leave or re-invite may race this insertion. + await ctx.call('chat.converse.syncConverseMember', { converseId }); + } + throw error; + } + return await this.transformDocuments(ctx, {}, res); } diff --git a/server/test/integration/chat/converse.spec.ts b/server/test/integration/chat/converse.spec.ts new file mode 100644 index 00000000..9727482c --- /dev/null +++ b/server/test/integration/chat/converse.spec.ts @@ -0,0 +1,682 @@ +import { Types } from 'mongoose'; +import { TcBroker } from 'tailchat-server-sdk'; +import ConverseService from '../../../services/core/chat/converse.service'; +import MessageService from '../../../services/core/chat/message.service'; +import UserService from '../../../services/core/user/user.service'; +import FriendService from '../../../services/core/user/friend.service'; +import UserDMListService from '../../../services/core/user/dmlist.service'; + +describe('Private conversation membership and invitation preferences', () => { + const broker = new TcBroker({ logger: false, cacher: 'Memory' }); + const converse = broker.createService(ConverseService) as ConverseService; + const message = broker.createService(MessageService) as MessageService; + const user = broker.createService(UserService) as UserService; + const friend = broker.createService(FriendService) as FriendService; + const dmlist = broker.createService(UserDMListService) as UserDMListService; + const notifications: any[] = []; + const rooms = new Map>(); + let users: string[]; + let converseIds: string[]; + let beforeJoin: (userId: string) => Promise; + let beforeLeave: (userId: string) => Promise; + + broker.createService({ + name: 'gateway', + actions: { + notify(ctx) { + notifications.push(ctx.params); + }, + async joinRoom(ctx) { + const userId = ctx.params.userId ?? ctx.meta.userId; + await beforeJoin?.(userId); + const joined = rooms.get(userId) ?? new Set(); + ctx.params.roomIds.forEach((id: string) => joined.add(id)); + rooms.set(userId, joined); + }, + async leaveRoom(ctx) { + await beforeLeave?.(ctx.params.userId ?? ctx.meta.userId); + ctx.params.roomIds.forEach((id: string) => + rooms.get(ctx.params.userId ?? ctx.meta.userId)?.delete(id) + ); + }, + checkUserOnline(ctx) { + return ctx.params.userIds.map(() => false); + }, + }, + }); + let joinedPanelIds: string[]; + broker.createService({ + name: 'group', + actions: { + getGroupInfo: () => ({ + members: users.map((userId) => ({ userId })), + panels: [], + }), + getJoinedGroupAndPanelIds: () => ({ + groupIds: [], + textPanelIds: joinedPanelIds, + subscribeFeaturePanelIds: [], + }), + }, + }); + + const asUser = (userId: string) => ({ + meta: { userId, user: { nickname: 'Test member' } }, + }); + const invoke = (action: string, userId: string, params = {}) => + broker.call(action, params, asUser(userId)); + const create = async (memberIds = users.slice(1)) => { + const result = await invoke('chat.converse.createDMConverse', users[0], { + memberIds, + }); + converseIds.push(result._id); + return result._id as string; + }; + const setPreference = (userId: string, enabled: boolean) => + invoke('user.setUserSettings', userId, { + settings: { onlyAllowFriendInvite: enabled }, + }); + + beforeAll(() => broker.start()); + beforeEach(async () => { + converseIds = []; + notifications.length = 0; + rooms.clear(); + beforeJoin = undefined; + beforeLeave = undefined; + joinedPanelIds = []; + users = await Promise.all( + Array.from({ length: 4 }, async () => { + const id = new Types.ObjectId(); + await user.adapter.model.create({ + _id: id, + email: `${id}@conversation.test`, + nickname: 'Test member', + password: 'unused-test-password', + discriminator: '0000', + }); + return String(id); + }) + ); + }); + afterEach(async () => { + jest.restoreAllMocks(); + const created = await converse.adapter.model.find({ + members: { $in: users.map((id) => new Types.ObjectId(id)) }, + }); + converseIds.push(...created.map((record) => String(record._id))); + await Promise.all([ + message.adapter.model.deleteMany({ converseId: { $in: converseIds } }), + converse.adapter.model.deleteMany({ _id: { $in: converseIds } }), + dmlist.adapter.model.deleteMany({ userId: { $in: users } }), + friend.adapter.model.deleteMany({ + $or: [{ from: { $in: users } }, { to: { $in: users } }], + }), + user.adapter.model.deleteMany({ _id: { $in: users } }), + ]); + }); + afterAll(() => broker.stop()); + + test.each([undefined, false])( + 'allows nonfriend invitations when preference is %s', + async (enabled) => { + if (enabled !== undefined) { + await setPreference(users[1], enabled); + } + const id = await create(users.slice(1, 3)); + await invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }); + expect( + (await converse.adapter.model.findById(id)).members.map(String).sort() + ).toEqual([...users].sort()); + } + ); + + test('rejects an opted-in nonfriend carried over from an existing direct message', async () => { + await setPreference(users[1], true); + await create([users[1]]); // One-to-one messages keep their existing policy. + await expect(create(users.slice(1, 3))).rejects.toThrow(); + expect( + await converse.adapter.model.countDocuments({ + type: 'Multi', + members: new Types.ObjectId(users[0]), + }) + ).toBe(0); + }); + + test('uses the invited user friendship direction and respects preference changes', async () => { + await setPreference(users[1], true); + await friend.adapter.model.create({ from: users[0], to: users[1] }); + await expect(create(users.slice(1, 3))).rejects.toThrow(); + await friend.adapter.model.create({ from: users[1], to: users[0] }); + await expect(create(users.slice(1, 3))).resolves.toEqual( + expect.any(String) + ); + await friend.adapter.model.deleteMany({ from: users[1], to: users[0] }); + await expect(create(users.slice(1, 3))).rejects.toThrow(); + await setPreference(users[1], false); + await expect(create(users.slice(1, 3))).resolves.toEqual( + expect.any(String) + ); + }); + + test('checks append invitations before mutating membership', async () => { + const id = await create(users.slice(1, 3)); + await setPreference(users[3], true); + await expect( + invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }) + ).rejects.toThrow(); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).not.toContain(users[3]); + await friend.adapter.model.create({ from: users[3], to: users[0] }); + await invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3], users[3]], + }); + expect( + (await converse.adapter.model.findById(id)).members + .map(String) + .filter((id) => id === users[3]) + ).toHaveLength(1); + }); + + test('append retries a failed socket join after membership was already added', async () => { + const id = await create(users.slice(1, 3)); + const append = () => + invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }); + beforeJoin = async () => { + throw new Error('Gateway unavailable'); + }; + await expect(append()).rejects.toThrow('Gateway unavailable'); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toContain(users[3]); + expect(rooms.get(users[3])?.has(id)).not.toBe(true); + + beforeJoin = undefined; + await setPreference(users[3], true); + const previousMessageCount = await message.adapter.model.countDocuments({ + converseId: id, + }); + await append(); + expect(rooms.get(users[3])?.has(id)).toBe(true); + expect( + (await invoke('user.dmlist.getAllConverse', users[3])).map(String) + ).toContain(id); + expect(await message.adapter.model.countDocuments({ converseId: id })).toBe( + previousMessageCount + ); + }); + + test('leaves membership, sockets and lists and cannot restore access by reconnecting or sending messages', async () => { + const id = await create(users.slice(1, 3)); + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + const stored = await converse.adapter.model.findById(id); + expect(stored.type).toBe('Multi'); + expect(stored.members.map(String)).toEqual([users[0], users[2]]); + expect(rooms.get(users[1])?.has(id)).toBe(false); + expect(notifications).toContainEqual({ + type: 'unicast', + target: users[1], + eventName: 'notify:chat.converse.removeDMConverse', + eventData: { converseId: id }, + }); + await expect( + invoke('chat.converse.findConverseInfo', users[1], { converseId: id }) + ).rejects.toThrow(); + await expect( + invoke('chat.message.fetchConverseMessage', users[1], { converseId: id }) + ).rejects.toThrow(); + await expect( + invoke('chat.message.searchMessage', users[1], { + converseId: id, + text: 'member', + }) + ).rejects.toThrow(); + const previous = await message.adapter.model.findOne({ converseId: id }); + for (const action of ['addReaction', 'removeReaction']) { + await expect( + invoke(`chat.message.${action}`, users[1], { + messageId: String(previous._id), + emoji: ':smile:', + }) + ).rejects.toThrow(); + } + await expect( + invoke('chat.message.sendMessage', users[1], { + converseId: id, + content: 'Not a member', + }) + ).rejects.toThrow(); + await expect( + invoke('user.dmlist.addConverse', users[1], { converseId: id }) + ).rejects.toThrow(); + await invoke('chat.message.sendMessage', users[0], { + converseId: id, + content: 'Still a member', + }); + expect( + (await invoke('user.dmlist.getAllConverse', users[1])).map(String) + ).not.toContain(id); + expect( + (await invoke('chat.converse.findAndJoinRoom', users[1])).dmConverseIds + ).not.toContain(id); + expect(rooms.get(users[1])?.has(id)).toBe(false); + await expect( + invoke('chat.message.fetchConverseMessage', users[0], { converseId: id }) + ).resolves.toHaveLength(2); + }); + + test('rejects nearby private history even when a departed member supplies an unrelated joined group', async () => { + const id = await create(users.slice(1, 3)); + const previous = await message.adapter.model.findOne({ converseId: id }); + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + await expect( + invoke('chat.message.fetchNearbyMessage', users[1], { + converseId: id, + messageId: String(previous._id), + groupId: String(new Types.ObjectId()), + }) + ).rejects.toThrow(); + }); + + test('checks joined group panels for panel ids without a private converse document', async () => { + const panelId = new Types.ObjectId(); + converseIds.push(String(panelId)); + const stored = await message.adapter.model.create({ + converseId: panelId, + groupId: new Types.ObjectId(), + author: new Types.ObjectId(users[0]), + content: 'Group history', + }); + await expect( + invoke('chat.message.fetchConverseMessage', users[0], { + converseId: String(panelId), + }) + ).rejects.toThrow(); + joinedPanelIds = [String(panelId)]; + const result = await invoke('chat.message.fetchConverseMessage', users[0], { + converseId: String(panelId), + }); + expect(result.map((item) => String(item._id))).toEqual([ + String(stored._id), + ]); + }); + + test('leave retries cleanup and does not affect another member', async () => { + const id = await create(users.slice(1, 3)); + const leave = () => + invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + await leave(); + await leave(); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toEqual([users[0], users[2]]); + expect( + (await invoke('user.dmlist.getAllConverse', users[1])).map(String) + ).not.toContain(id); + }); + + test('leave by a nonmember changes nothing and does not notify the room', async () => { + const id = await create(users.slice(1, 3)); + notifications.length = 0; + await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id }); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toEqual(users.slice(0, 3)); + expect(notifications.filter((event) => event.type === 'roomcast')).toEqual( + [] + ); + }); + + test('does not mutate direct messages or groups through append or leave', async () => { + const direct = await create([users[1]]); + const group = await converse.adapter.model.create({ + type: 'Group', + members: users.slice(0, 2), + }); + converseIds.push(String(group._id)); + for (const id of [direct, String(group._id)]) { + await expect( + invoke('chat.converse.leaveDMConverse', users[0], { converseId: id }) + ).rejects.toThrow(); + await expect( + invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[2]], + }) + ).rejects.toThrow(); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toEqual(users.slice(0, 2)); + } + }); + + test('does not reuse a multi conversation reduced to two members as a direct message', async () => { + const id = await create(users.slice(1, 3)); + await invoke('chat.converse.leaveDMConverse', users[2], { converseId: id }); + const direct = await create([users[1]]); + expect(direct).not.toBe(id); + expect((await converse.adapter.model.findById(direct)).type).toBe('DM'); + }); + + test('concurrent appends and leaves preserve unrelated membership updates', async () => { + const id = await create(users.slice(1, 3)); + await Promise.all([ + invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }), + invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }), + ]); + expect( + (await converse.adapter.model.findById(id)).members.map(String).sort() + ).toEqual([users[0], users[2], users[3]].sort()); + }); + test('removes a delayed list insertion that races with leaving', async () => { + const id = await create(users.slice(1, 3)); + await invoke('user.dmlist.removeConverse', users[1], { converseId: id }); + const record = await dmlist.adapter.model.findOne({ userId: users[1] }); + let resume: () => void; + let started: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const inserting = new Promise((resolve) => { + started = resolve; + }); + const original = dmlist.adapter.model.findByIdAndUpdate.bind( + dmlist.adapter.model + ); + jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation((( + recordId, + ...args + ) => { + if (String(recordId) === String(record._id)) { + return paused.then(() => original(recordId, ...args)); + } + return original(recordId, ...args); + }) as any); + const originalFind = dmlist.adapter.model.findOrCreate.bind( + dmlist.adapter.model + ); + jest.spyOn(dmlist.adapter.model, 'findOrCreate').mockImplementation((async ( + ...args + ) => { + const result = await originalFind(...args); + started(); + return result; + }) as any); + const pending = invoke('user.dmlist.addConverse', users[1], { + converseId: id, + }); + const rejected = expect(pending).rejects.toThrow(); + await inserting; + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + resume(); + await rejected; + expect( + (await dmlist.adapter.model.findById(record._id)).converseIds.map(String) + ).not.toContain(id); + }); + + test('a delayed list compensation cannot remove a successful re-invitation', async () => { + const id = await create(users.slice(1, 3)); + const record = await dmlist.adapter.model.findOne({ userId: users[1] }); + let resumeInsert: () => void; + let inserted: () => void; + const insertPaused = new Promise((resolve) => { + resumeInsert = resolve; + }); + const insertion = new Promise((resolve) => { + inserted = resolve; + }); + const originalAdd = dmlist.adapter.model.findByIdAndUpdate.bind( + dmlist.adapter.model + ); + let pauseInsert = true; + jest.spyOn(dmlist.adapter.model, 'findByIdAndUpdate').mockImplementation((( + recordId, + ...args + ) => { + if (pauseInsert && String(recordId) === String(record._id)) { + pauseInsert = false; + return (async () => { + const result = await originalAdd(recordId, ...args); + inserted(); + await insertPaused; + return result; + })(); + } + return originalAdd(recordId, ...args); + }) as any); + const pending = invoke('user.dmlist.addConverse', users[1], { + converseId: id, + }); + const rejected = expect(pending).rejects.toThrow(); + await insertion; + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + + let resumeRemove: () => void; + let removing: () => void; + const removePaused = new Promise((resolve) => { + resumeRemove = resolve; + }); + const removal = new Promise((resolve) => { + removing = resolve; + }); + const originalRemove = dmlist.adapter.model.updateOne.bind( + dmlist.adapter.model + ); + let pauseRemove = true; + jest.spyOn(dmlist.adapter.model, 'updateOne').mockImplementation((( + filter, + ...args + ) => { + if (pauseRemove && String(filter.userId) === users[1]) { + pauseRemove = false; + return { + exec: async () => { + removing(); + await removePaused; + return originalRemove(filter, ...args).exec(); + }, + }; + } + return originalRemove(filter, ...args); + }) as any); + resumeInsert(); + await removal; + await invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[1]], + }); + resumeRemove(); + await rejected; + expect( + (await invoke('user.dmlist.getAllConverse', users[1])).map(String) + ).toContain(id); + expect(rooms.get(users[1]).has(id)).toBe(true); + }); + + test('removes a delayed reconnect join after the member leaves', async () => { + const id = await create(users.slice(1, 3)); + let resume: () => void; + let started: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const joining = new Promise((resolve) => { + started = resolve; + }); + beforeJoin = async (userId) => { + if (userId === users[1]) { + started(); + await paused; + } + }; + const reconnect = invoke('chat.converse.findAndJoinRoom', users[1]); + await joining; + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + resume(); + expect((await reconnect).dmConverseIds).not.toContain(id); + expect(rooms.get(users[1]).has(id)).toBe(false); + }); + + test('cleans a delayed invitation join when the invited member leaves', async () => { + const id = await create(users.slice(1, 3)); + let resume: () => void; + let started: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const joining = new Promise((resolve) => { + started = resolve; + }); + beforeJoin = async (userId) => { + if (userId === users[3]) { + started(); + await paused; + } + }; + const append = invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }); + await joining; + await invoke('chat.converse.leaveDMConverse', users[3], { converseId: id }); + resume(); + await append; + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).not.toContain(users[3]); + expect(rooms.get(users[3]).has(id)).toBe(false); + expect( + (await invoke('user.dmlist.getAllConverse', users[3])).map(String) + ).not.toContain(id); + }); + + test('rejects an append if its actor leaves during preference validation', async () => { + const id = await create(users.slice(1, 3)); + let resume: () => void; + let started: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const checking = new Promise((resolve) => { + started = resolve; + }); + const original = user.adapter.model.findOne.bind(user.adapter.model); + jest.spyOn(user.adapter.model, 'findOne').mockImplementation((( + filter, + ...args + ) => { + if (String(filter?._id) === users[3]) { + started(); + return paused.then(() => original(filter, ...args)); + } + return original(filter, ...args); + }) as any); + const append = invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[3]], + }); + const rejected = expect(append).rejects.toThrow(); + await checking; + await invoke('chat.converse.leaveDMConverse', users[0], { converseId: id }); + resume(); + await rejected; + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toEqual(users.slice(1, 3)); + }); + + test('restores current membership when a re-invite finishes before delayed leave cleanup', async () => { + const id = await create(users.slice(1, 3)); + let resume: () => void; + let started: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const leaving = new Promise((resolve) => { + started = resolve; + }); + beforeLeave = async (userId) => { + if (userId === users[1]) { + started(); + await paused; + } + }; + const leave = invoke('chat.converse.leaveDMConverse', users[1], { + converseId: id, + }); + await leaving; + await invoke('chat.converse.appendDMConverseMembers', users[0], { + converseId: id, + memberIds: [users[1]], + }); + resume(); + await leave; + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).toContain(users[1]); + expect(rooms.get(users[1]).has(id)).toBe(true); + expect( + (await invoke('user.dmlist.getAllConverse', users[1])).map(String) + ).toContain(id); + expect( + notifications.filter((event) => event.target === users[1]).slice(-1)[0] + .eventName + ).toBe('notify:chat.converse.updateDMConverse'); + }); + + test('leave can retry a socket cleanup failure without restoring membership', async () => { + const id = await create(users.slice(1, 3)); + beforeLeave = async () => { + throw new Error('Gateway unavailable'); + }; + await expect( + invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }) + ).rejects.toThrow('Gateway unavailable'); + expect( + (await converse.adapter.model.findById(id)).members.map(String) + ).not.toContain(users[1]); + beforeLeave = undefined; + await invoke('chat.converse.leaveDMConverse', users[1], { converseId: id }); + expect(rooms.get(users[1]).has(id)).toBe(false); + expect( + (await invoke('user.dmlist.getAllConverse', users[1])).map(String) + ).not.toContain(id); + }); + + test('fails closed when the invitation preference cannot be loaded', async () => { + const original = user.adapter.model.findOne.bind(user.adapter.model); + jest.spyOn(user.adapter.model, 'findOne').mockImplementation((( + filter, + ...args + ) => { + if (String(filter?._id) === users[1]) { + throw new Error('Settings unavailable'); + } + return original(filter, ...args); + }) as any); + await expect(create(users.slice(1, 3))).rejects.toThrow( + 'Settings unavailable' + ); + expect( + await converse.adapter.model.countDocuments({ + type: 'Multi', + members: new Types.ObjectId(users[0]), + }) + ).toBe(0); + }); +}); diff --git a/server/test/integration/chat/message.spec.ts b/server/test/integration/chat/message.spec.ts index 198db01e..879e86da 100644 --- a/server/test/integration/chat/message.spec.ts +++ b/server/test/integration/chat/message.spec.ts @@ -16,8 +16,60 @@ function createTestMessage(converseId: Types.ObjectId, content = 'bar') { } describe('Test "chat.message" service', () => { - const { broker, service, insertTestData } = - createTestServiceBroker(MessageService); + const { broker, service, insertTestData, contextCallMock } = + createTestServiceBroker(MessageService, { + contextCallMockFn: function ( + this: { meta: { userId: string } }, + actionName + ) { + if (actionName === 'user.getUserInfo') { + return { type: 'normal' }; + } + if (actionName === 'chat.converse.findConverseInfo') { + return { type: 'Multi', members: [this.meta.userId] }; + } + }, + }); + + test('returns the persisted message when the online status lookup fails', async () => { + const converseId = String(new Types.ObjectId()); + const userId = String(new Types.ObjectId()); + const defaultCall = contextCallMock.getMockImplementation(); + const emit = jest.spyOn(broker, 'emit'); + contextCallMock.mockImplementation(function (actionName, ...args) { + if (actionName === 'gateway.checkUserOnline') { + throw new Error('Online status unavailable'); + } + return defaultCall.call(this, actionName, ...args); + }); + + try { + const result: MessageDocument = await broker.call( + 'chat.message.sendMessage', + { converseId, content: 'Persisted message' }, + { meta: { userId } } + ); + + expect(String(result.converseId)).toBe(converseId); + expect(result.content).toBe('Persisted message'); + const messages = await service.adapter.model.find({ converseId }); + expect(messages).toHaveLength(1); + expect(String(messages[0]._id)).toBe(String(result._id)); + expect(emit).toHaveBeenCalledWith( + 'chat.message.updateMessage', + expect.objectContaining({ + type: 'add', + converseId, + messageId: String(result._id), + }), + expect.any(Object) + ); + } finally { + contextCallMock.mockImplementation(defaultCall); + emit.mockRestore(); + await service.adapter.model.deleteMany({ converseId }); + } + }); describe('Test slow mode bypass policy', () => { test('plugin bots keep converse access but do not bypass slow mode', async () => { diff --git a/website/docs/advanced-usage/conversations.md b/website/docs/advanced-usage/conversations.md new file mode 100644 index 00000000..dbbac605 --- /dev/null +++ b/website/docs/advanced-usage/conversations.md @@ -0,0 +1,26 @@ +--- +title: Private conversations +--- + +## Leave a multi-person conversation + +Use **Leave conversation** in a multi-person conversation to remove yourself from +its members. After leaving, you no longer receive its messages or have access to +the conversation. Your other connected devices also remove it from the list. + +Closing a conversation in the sidebar only hides it. You remain a member, and a +new message can bring it back. Leaving is available even when a multi-person +conversation has only one or two members left. + +## Allow invitations only from friends + +In **Settings → System settings**, enable **Only allow friends to invite me to +conversations**. This setting is off by default. + +When enabled, someone must be in your friend list to include you in a new +multi-person conversation or add you to an existing one. This also applies when +an existing direct message is used to create a multi-person conversation. + +The setting does not remove you from existing conversations or block one-to-one +direct messages. Leaving a conversation does not prevent a later invitation; +enable this setting if you want to reject invitations from non-friends. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-usage/conversations.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-usage/conversations.md new file mode 100644 index 00000000..f90b130d --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/advanced-usage/conversations.md @@ -0,0 +1,17 @@ +--- +title: 私人会话 +--- + +## 退出多人会话 + +在多人会话中使用**退出会话**,即可将自己从成员中移除。退出后,你将不再收到该会话的消息,也无法继续访问会话。其他已连接的设备也会将它从会话列表中移除。 + +侧边栏中的关闭操作只会隐藏会话,你仍然是成员,新消息可能使会话重新出现。即使多人会话只剩一两名成员,仍然可以退出。 + +## 仅允许好友邀请 + +在**设置 → 系统设置**中启用**仅允许好友邀请创建会话**。这个开关默认关闭。 + +开启后,只有在你的好友列表中的用户,才能将你加入新建的多人会话或已有的多人会话。从已有私聊创建多人会话时,同样遵守这个设置。 + +这个设置不会让你退出已经加入的会话,也不影响一对一私聊。退出会话本身不会阻止后续邀请;如果希望拒绝非好友的邀请,请开启此设置。