feat: Video Conference persistent chat (#32793)

Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com>
pull/32792/head
Pierre Lehnen 2 years ago committed by GitHub
parent 1195dc247e
commit 2d89a0c448
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      .changeset/nice-laws-eat.md
  2. 4
      apps/meteor/app/apps/server/bridges/videoConferences.ts
  3. 4
      apps/meteor/app/discussion/server/hooks/propagateDiscussionMetadata.ts
  4. 18
      apps/meteor/app/lib/server/functions/addUserToRoom.ts
  5. 2
      apps/meteor/app/slackbridge/server/SlackAdapter.js
  6. 2
      apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts
  7. 4
      apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx
  8. 27
      apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx
  9. 6
      apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfRecordList.ts
  10. 19
      apps/meteor/ee/server/settings/video-conference.ts
  11. 5
      apps/meteor/lib/callbacks.ts
  12. 22
      apps/meteor/server/models/raw/VideoConference.ts
  13. 9
      apps/meteor/server/services/room/service.ts
  14. 6
      apps/meteor/server/services/team/service.ts
  15. 324
      apps/meteor/server/services/video-conference/service.ts
  16. 2
      apps/meteor/tests/data/apps/apps-data.ts
  17. 5
      apps/meteor/tests/end-to-end/apps/installation.ts
  18. 512
      apps/meteor/tests/end-to-end/apps/video-conferences.ts
  19. 7
      packages/core-services/src/types/IRoomService.ts
  20. 1
      packages/core-services/src/types/IVideoConfService.ts
  21. 1
      packages/core-typings/src/IVideoConference.ts
  22. 1
      packages/core-typings/src/VideoConferenceCapabilities.ts
  23. 38
      packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx
  24. 5
      packages/i18n/src/locales/en.i18n.json
  25. 6
      packages/model-typings/src/models/IVideoConferenceModel.ts

@ -0,0 +1,15 @@
---
'rocketchat-services': minor
'@rocket.chat/core-services': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/ui-video-conf': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/ui-contexts': minor
'@rocket.chat/models': minor
'@rocket.chat/ui-kit': minor
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---
New Feature: Video Conference Persistent Chat.
This feature provides a discussion id for conference provider apps to store the chat messages exchanged during the conferences, so that those users may then access those messages again at any time through Rocket.Chat.

@ -59,6 +59,10 @@ export class AppVideoConferenceBridge extends VideoConferenceBridge {
if (data.status > oldData.status) {
await VideoConf.setStatus(call._id, data.status);
}
if (data.discussionRid !== oldData.discussionRid) {
await VideoConf.assignDiscussionToConference(call._id, data.discussionRid);
}
}
protected async registerProvider(info: IVideoConfProvider, appId: string): Promise<void> {

@ -1,5 +1,5 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import { Messages, Rooms, VideoConference } from '@rocket.chat/models';
import { callbacks } from '../../../../lib/callbacks';
import { broadcastMessageFromData } from '../../../../server/modules/watchers/lib/messages';
@ -108,6 +108,8 @@ callbacks.add(
},
},
);
await VideoConference.unsetDiscussionRid(drid);
return drid;
},
callbacks.priority.LOW,

@ -15,9 +15,15 @@ import { notifyOnRoomChangedById } from '../lib/notifyListener';
export const addUserToRoom = async function (
rid: string,
user: Pick<IUser, '_id' | 'username'> | string,
user: Pick<IUser, '_id'> | string,
inviter?: Pick<IUser, '_id' | 'username'>,
silenced?: boolean,
{
skipSystemMessage,
skipAlertSound,
}: {
skipSystemMessage?: boolean;
skipAlertSound?: boolean;
} = {},
): Promise<boolean | undefined> {
const now = new Date();
const room = await Rooms.findOneById(rid);
@ -43,12 +49,12 @@ export const addUserToRoom = async function (
}
try {
await callbacks.run('federation.beforeAddUserToARoom', { user, inviter }, room);
await callbacks.run('federation.beforeAddUserToARoom', { user: userToBeAdded, inviter }, room);
} catch (error) {
throw new Meteor.Error((error as any)?.message);
}
await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter: userToBeAdded });
await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter });
// Check if user is already in room
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id);
@ -79,7 +85,7 @@ export const addUserToRoom = async function (
await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, {
ts: now,
open: true,
alert: true,
alert: !skipAlertSound,
unread: 1,
userMentions: 1,
groupMentions: 0,
@ -93,7 +99,7 @@ export const addUserToRoom = async function (
throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username');
}
if (!silenced) {
if (!skipSystemMessage) {
if (inviter) {
const extraData = {
ts: now,

@ -1341,7 +1341,7 @@ export default class SlackAdapter {
const user = (await this.rocket.findUser(member)) || (await this.rocket.addUser(member));
if (user) {
slackLogger.debug('Adding user to room', user.username, rid);
await addUserToRoom(rid, user, null, true);
await addUserToRoom(rid, user, null, { skipSystemMessage: true });
}
}
}

@ -21,7 +21,7 @@ export const useCallsRoomAction = () => {
return {
id: 'calls',
groups: ['channel', 'group', 'team'],
groups: ['channel', 'group', 'team', 'direct', 'direct_multiple'],
icon: 'phone',
title: 'Calls',
...(federated && {

@ -1,4 +1,4 @@
import type { IGroupVideoConference } from '@rocket.chat/core-typings';
import type { VideoConference } from '@rocket.chat/core-typings';
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle, Throbber } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
@ -21,7 +21,7 @@ import VideoConfListItem from './VideoConfListItem';
type VideoConfListProps = {
onClose: () => void;
total: number;
videoConfs: IGroupVideoConference[];
videoConfs: VideoConference[];
loading: boolean;
error?: Error;
reload: () => void;

@ -1,6 +1,6 @@
import type { IGroupVideoConference } from '@rocket.chat/core-typings';
import type { VideoConference } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Button, Message, Box, Avatar, Palette } from '@rocket.chat/fuselage';
import { Button, Message, Box, Avatar, Palette, IconButton, ButtonGroup } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
@ -10,6 +10,7 @@ import React from 'react';
import { useVideoConfJoinCall } from '../../../../../contexts/VideoConfContext';
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
import { VIDEOCONF_STACK_MAX_USERS } from '../../../../../lib/constants';
import { useGoToRoom } from '../../../hooks/useGoToRoom';
const VideoConfListItem = ({
videoConfData,
@ -17,7 +18,7 @@ const VideoConfListItem = ({
reload,
...props
}: {
videoConfData: IGroupVideoConference;
videoConfData: VideoConference;
className?: string[];
reload: () => void;
}): ReactElement => {
@ -32,6 +33,7 @@ const VideoConfListItem = ({
users,
createdAt,
endedAt,
discussionRid,
} = videoConfData;
const joinedUsers = users.filter((user) => user._id !== _id);
@ -51,6 +53,8 @@ const VideoConfListItem = ({
return reload();
});
const goToRoom = useGoToRoom();
return (
<Box
color='default'
@ -70,9 +74,20 @@ const VideoConfListItem = ({
<Message.Body clamp={2} />
<Box display='flex'></Box>
<Message.Block flexDirection='row' alignItems='center'>
<Button disabled={Boolean(endedAt)} small alignItems='center' display='flex' onClick={handleJoinConference}>
{endedAt ? t('Call_ended') : t('Join_call')}
</Button>
<ButtonGroup>
<Button disabled={Boolean(endedAt)} small alignItems='center' display='flex' onClick={handleJoinConference}>
{endedAt ? t('Call_ended') : t('Join_call')}
</Button>
{discussionRid && (
<IconButton
small
icon='discussion'
data-drid={discussionRid}
title={t('Join_discussion')}
onClick={() => goToRoom(discussionRid)}
/>
)}
</ButtonGroup>
{joinedUsers.length > 0 && (
<Box mis={8} fontScale='c1' display='flex' alignItems='center'>
<Avatar.Stack>

@ -1,9 +1,9 @@
import type { IGroupVideoConference } from '@rocket.chat/core-typings';
import type { VideoConference } from '@rocket.chat/core-typings';
import { RecordList } from '../../../../../lib/lists/RecordList';
export class VideoConfRecordList extends RecordList<IGroupVideoConference> {
protected compare(a: IGroupVideoConference, b: IGroupVideoConference): number {
export class VideoConfRecordList extends RecordList<VideoConference> {
protected compare(a: VideoConference, b: VideoConference): number {
return b.createdAt.getTime() - a.createdAt.getTime();
}
}

@ -31,6 +31,25 @@ export function addSettings(): Promise<void> {
public: true,
invalidValue: true,
});
const discussionsEnabled = { _id: 'Discussion_enabled', value: true };
await this.add('VideoConf_Enable_Persistent_Chat', false, {
type: 'boolean',
public: true,
invalidValue: false,
alert: 'VideoConf_Enable_Persistent_Chat_Alert',
enableQuery: [discussionsEnabled],
});
const persistentChatEnabled = { _id: 'VideoConf_Enable_Persistent_Chat', value: true };
await this.add('VideoConf_Persistent_Chat_Discussion_Name', 'Conference Call Chat History', {
type: 'string',
public: true,
invalidValue: 'Conference Call Chat History',
enableQuery: [discussionsEnabled, persistentChatEnabled],
});
},
);
});

@ -63,7 +63,10 @@ interface EventLikeCallbackSignatures {
) => void;
'livechat.afterAgentRemoved': (params: { agent: Pick<IUser, '_id' | 'username'> }) => void;
'afterAddedToRoom': (params: { user: IUser; inviter?: IUser }, room: IRoom) => void;
'beforeAddedToRoom': (params: { user: AtLeast<IUser, '_id' | 'federated' | 'roles'>; inviter: IUser }) => void;
'beforeAddedToRoom': (params: {
user: AtLeast<IUser, '_id' | 'federated' | 'roles'>;
inviter: AtLeast<IUser, '_id' | 'username'>;
}) => void;
'afterCreateDirectRoom': (params: IRoom, second: { members: IUser[]; creatorId: IUser['_id'] }) => void;
'beforeDeleteRoom': (params: IRoom) => void;
'beforeJoinDefaultChannels': (user: IUser) => void;

@ -21,6 +21,7 @@ export class VideoConferenceRaw extends BaseRaw<VideoConference> implements IVid
return [
{ key: { rid: 1, createdAt: 1 }, unique: false },
{ key: { type: 1, status: 1 }, unique: false },
{ key: { discussionRid: 1 }, unique: false },
];
}
@ -263,4 +264,25 @@ export class VideoConferenceRaw extends BaseRaw<VideoConference> implements IVid
},
);
}
public async setDiscussionRidById(callId: string, discussionRid: IRoom['_id']): Promise<void> {
await this.updateOne({ _id: callId }, { $set: { discussionRid } });
}
public async unsetDiscussionRidById(callId: string): Promise<void> {
await this.updateOne({ _id: callId }, { $unset: { discussionRid: true } });
}
public async unsetDiscussionRid(discussionRid: IRoom['_id']): Promise<void> {
await this.updateMany(
{
discussionRid,
},
{
$unset: {
discussionRid: 1,
},
},
);
}
}

@ -59,11 +59,14 @@ export class RoomService extends ServiceClassInternal implements IRoomService {
async addUserToRoom(
roomId: string,
user: Pick<IUser, '_id' | 'username'> | string,
user: Pick<IUser, '_id'> | string,
inviter?: Pick<IUser, '_id' | 'username'>,
silenced?: boolean,
options?: {
skipSystemMessage?: boolean;
skipAlertSound?: boolean;
},
): Promise<boolean | undefined> {
return addUserToRoom(roomId, user, inviter, silenced);
return addUserToRoom(roomId, user, inviter, options);
}
async removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: IUser }): Promise<void> {

@ -718,7 +718,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
for await (const member of members) {
const user = (await Users.findOneById(member.userId, { projection: { username: 1 } })) as Pick<IUser, '_id' | 'username'>;
await addUserToRoom(team.roomId, user, createdBy, false);
await addUserToRoom(team.roomId, user, createdBy, { skipSystemMessage: false });
if (member.roles) {
await this.addRolesToMember(teamId, member.userId, member.roles);
@ -826,7 +826,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
return;
}
await addUserToRoom(team.roomId, user, inviter, false);
await addUserToRoom(team.roomId, user, inviter, { skipSystemMessage: false });
}),
);
}
@ -977,7 +977,7 @@ export class TeamService extends ServiceClassInternal implements ITeamService {
// at this point, users are already part of the team so we won't check for membership
for await (const user of users) {
// add each user to the default room
await addUserToRoom(room._id, user, inviter, false);
await addUserToRoom(room._id, user, inviter, { skipSystemMessage: false });
}
});
}

@ -1,7 +1,8 @@
import { Apps } from '@rocket.chat/apps';
import type { VideoConfData, VideoConfDataExtended } from '@rocket.chat/apps-engine/definition/videoConfProviders';
import type { AppVideoConfProviderManager } from '@rocket.chat/apps-engine/server/managers';
import type { IVideoConfService, VideoConferenceJoinOptions } from '@rocket.chat/core-services';
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
import { api, ServiceClassInternal, Room } from '@rocket.chat/core-services';
import type {
IDirectVideoConference,
ILivechatVideoConference,
@ -27,13 +28,17 @@ import {
isGroupVideoConference,
isLivechatVideoConference,
} from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users, VideoConference as VideoConferenceModel, Rooms, Messages, Subscriptions } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
import { wrapExceptions } from '@rocket.chat/tools';
import type * as UiKit from '@rocket.chat/ui-kit';
import { MongoInternals } from 'meteor/mongo';
import { RocketChatAssets } from '../../../app/assets/server';
import { canAccessRoomIdAsync } from '../../../app/authorization/server/functions/canAccessRoom';
import { createRoom } from '../../../app/lib/server/functions/createRoom';
import { sendMessage } from '../../../app/lib/server/functions/sendMessage';
import { metrics } from '../../../app/metrics/server/lib/metrics';
import PushNotification from '../../../app/push-notifications/server/lib/PushNotification';
@ -47,12 +52,15 @@ import { availabilityErrors } from '../../../lib/videoConference/constants';
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
import { i18n } from '../../lib/i18n';
import { isRoomCompatibleWithVideoConfRinging } from '../../lib/isRoomCompatibleWithVideoConfRinging';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { videoConfProviders } from '../../lib/videoConfProviders';
import { videoConfTypes } from '../../lib/videoConfTypes';
import { broadcastMessageFromData } from '../../modules/watchers/lib/messages';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
const logger = new Logger('VideoConference');
export class VideoConfService extends ServiceClassInternal implements IVideoConfService {
protected name = 'video-conference';
@ -61,33 +69,41 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
{ type, rid, createdBy, providerName, ...data }: VideoConferenceCreateData,
useAppUser = true,
): Promise<VideoConferenceInstructions> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'uids' | 'name' | 'fname'>>(rid, {
projection: { t: 1, uids: 1, name: 1, fname: 1 },
});
if (!room) {
throw new Error('invalid-room');
}
return wrapExceptions(async () => {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'uids' | 'name' | 'fname'>>(rid, {
projection: { t: 1, uids: 1, name: 1, fname: 1 },
});
const user = await Users.findOneById<IUser>(createdBy, {});
if (!user) {
throw new Error('failed-to-load-own-data');
}
if (!room) {
throw new Error('invalid-room');
}
if (type === 'direct') {
if (!isRoomCompatibleWithVideoConfRinging(room.t, room.uids)) {
throw new Error('type-and-room-not-compatible');
const user = await Users.findOneById<IUser>(createdBy);
if (!user) {
throw new Error('failed-to-load-own-data');
}
return this.startDirect(providerName, user, room, data);
}
if (type === 'direct') {
if (!isRoomCompatibleWithVideoConfRinging(room.t, room.uids)) {
throw new Error('type-and-room-not-compatible');
}
if (type === 'livechat') {
return this.startLivechat(providerName, user, rid);
}
return this.startDirect(providerName, user, room, data);
}
const title = (data as Partial<IGroupVideoConference>).title || room.fname || room.name || '';
return this.startGroup(providerName, user, room._id, title, data, useAppUser);
if (type === 'livechat') {
return this.startLivechat(providerName, user, rid);
}
const title = (data as Partial<IGroupVideoConference>).title || room.fname || room.name || '';
return this.startGroup(providerName, user, room._id, title, data, useAppUser);
}).catch((e) => {
logger.error({
name: 'Error on VideoConf.create',
error: e,
});
throw e;
});
}
// VideoConference.start: Detect the desired type and provider then start a video conference using them
@ -96,45 +112,61 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
rid: string,
{ title, allowRinging }: { title?: string; allowRinging?: boolean },
): Promise<VideoConferenceInstructions> {
const providerName = await this.getValidatedProvider();
const initialData = await this.getTypeForNewVideoConference(rid, Boolean(allowRinging));
const data = {
...initialData,
createdBy: caller,
rid,
providerName,
} as VideoConferenceCreateData;
if (data.type === 'videoconference') {
data.title = title;
}
return wrapExceptions(async () => {
const providerName = await this.getValidatedProvider();
const initialData = await this.getTypeForNewVideoConference(rid, Boolean(allowRinging));
const data = {
...initialData,
createdBy: caller,
rid,
providerName,
} as VideoConferenceCreateData;
if (data.type === 'videoconference') {
data.title = title;
}
return this.create(data, false);
return this.create(data, false);
}).catch((e) => {
logger.error({
name: 'Error on VideoConf.start',
error: e,
});
throw e;
});
}
public async join(uid: IUser['_id'] | undefined, callId: VideoConference['_id'], options: VideoConferenceJoinOptions): Promise<string> {
const call = await VideoConferenceModel.findOneById(callId);
if (!call || call.endedAt) {
throw new Error('invalid-call');
}
return wrapExceptions(async () => {
const call = await VideoConferenceModel.findOneById(callId);
if (!call || call.endedAt) {
throw new Error('invalid-call');
}
let user: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
let user: Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'> | null = null;
if (uid) {
user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, {
projection: { name: 1, username: 1, avatarETag: 1 },
});
if (!user) {
throw new Error('failed-to-load-own-data');
if (uid) {
user = await Users.findOneById<Pick<IUser, '_id' | 'username' | 'name' | 'avatarETag'>>(uid, {
projection: { name: 1, username: 1, avatarETag: 1 },
});
if (!user) {
throw new Error('failed-to-load-own-data');
}
}
}
if (call.providerName === 'jitsi') {
updateCounter({ settingsId: 'Jitsi_Click_To_Join_Count' });
}
if (call.providerName === 'jitsi') {
updateCounter({ settingsId: 'Jitsi_Click_To_Join_Count' });
}
return this.joinCall(call, user || undefined, options);
return this.joinCall(call, user || undefined, options);
}).catch((e) => {
logger.error({
name: 'Error on VideoConf.join',
error: e,
});
throw e;
});
}
public async getInfo(callId: VideoConference['_id'], uid: IUser['_id'] | undefined): Promise<UiKit.LayoutBlock[]> {
@ -686,13 +718,15 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
},
providerName,
});
await this.runNewVideoConferenceEvent(callId);
await this.maybeCreateDiscussion(callId, user);
const call = (await this.getUnfiltered(callId)) as IDirectVideoConference | null;
if (!call) {
throw new Error('failed-to-create-direct-call');
}
await this.runNewVideoConferenceEvent(callId);
const url = await this.generateNewUrl(call);
await VideoConferenceModel.setUrlById(callId, url);
@ -759,13 +793,16 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
},
providerName,
});
await this.runNewVideoConferenceEvent(callId);
await this.maybeCreateDiscussion(callId, user);
const call = (await this.getUnfiltered(callId)) as IGroupVideoConference | null;
if (!call) {
throw new Error('failed-to-create-group-call');
}
await this.runNewVideoConferenceEvent(callId);
const url = await this.generateNewUrl(call);
await VideoConferenceModel.setUrlById(callId, url);
@ -804,6 +841,8 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await this.runNewVideoConferenceEvent(callId);
// Livechat conferences do not use discussions
const joinUrl = await this.getUrl(call);
const messageId = await this.createLivechatMessage(call, user, joinUrl);
call.messages.started = messageId;
@ -852,19 +891,17 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
}
const title = isGroupVideoConference(call) ? call.title || (await this.getRoomName(call.rid)) : '';
const callData: VideoConfData = {
_id: call._id,
type: call.type,
rid: call.rid,
createdBy: call.createdBy as Required<VideoConference['createdBy']>,
title,
providerData: call.providerData,
discussionRid: call.discussionRid,
};
return (await this.getProviderManager())
.generateUrl(call.providerName, {
_id: call._id,
type: call.type,
rid: call.rid,
createdBy: call.createdBy as Required<VideoConference['createdBy']>,
title,
providerData: call.providerData,
})
.catch((e) => {
throw new Error(e);
});
return (await this.getProviderManager()).generateUrl(call.providerName, callData);
}
private async getCallTitleForUser(call: VideoConference, userId?: IUser['_id']): Promise<string> {
@ -920,7 +957,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await VideoConferenceModel.setUrlById(call._id, call.url);
}
const callData = {
const callData: VideoConfDataExtended = {
_id: call._id,
type: call.type,
rid: call.rid,
@ -931,6 +968,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
...{ customCallTitle: await this.getCallTitleForUser(call, user?._id) },
},
title: await this.getCallTitle(call),
discussionRid: call.discussionRid,
};
const userData = user && {
@ -939,9 +977,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
name: user.name as string,
};
return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options).catch((e) => {
throw new Error(e);
});
return (await this.getProviderManager()).customizeUrl(call.providerName, callData, userData, options);
}
private async runNewVideoConferenceEvent(callId: VideoConference['_id']): Promise<void> {
@ -955,9 +991,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
throw new Error('video-conf-provider-unavailable');
}
(await this.getProviderManager()).onNewVideoConference(call.providerName, call).catch((e) => {
throw new Error(e);
});
return (await this.getProviderManager()).onNewVideoConference(call.providerName, call);
}
private async runVideoConferenceChangedEvent(callId: VideoConference['_id']): Promise<void> {
@ -971,9 +1005,7 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
throw new Error('video-conf-provider-unavailable');
}
(await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call).catch((e) => {
throw new Error(e);
});
return (await this.getProviderManager()).onVideoConferenceChanged(call.providerName, call);
}
private async runOnUserJoinEvent(callId: VideoConference['_id'], user?: IVideoConferenceUser): Promise<void> {
@ -987,15 +1019,19 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
throw new Error('video-conf-provider-unavailable');
}
(await this.getProviderManager()).onUserJoin(call.providerName, call, user).catch((e) => {
throw new Error(e);
});
return (await this.getProviderManager()).onUserJoin(call.providerName, call, user);
}
private async addUserToCall(
call: Optional<VideoConference, 'providerData'>,
{ _id, username, name, avatarETag, ts }: AtLeast<Required<IUser>, '_id' | 'username' | 'name' | 'avatarETag'> & { ts?: Date },
): Promise<void> {
// If the call has a discussion, ensure the user is subscribed to it;
// This is done even if the user has already joined the call before, so they can be added back if they had left the discussion.
if (call.discussionRid) {
await this.addUserToDiscussion(call.discussionRid, _id);
}
if (call.users.find((user) => user._id === _id)) {
return;
}
@ -1034,4 +1070,128 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
await this.runVideoConferenceChangedEvent(call._id);
await this.sendAllPushNotifications(call._id);
}
private isPersistentChatEnabled(): boolean {
return settings.get<boolean>('VideoConf_Enable_Persistent_Chat') && settings.get<boolean>('Discussion_enabled');
}
private async maybeCreateDiscussion(callId: VideoConference['_id'], createdBy?: IUser): Promise<void> {
if (!this.isPersistentChatEnabled()) {
return;
}
const call = await VideoConferenceModel.findOneById(callId, {
projection: { rid: 1, createdBy: 1, discussionRid: 1, providerName: 1 },
});
if (!call) {
throw new Error('invalid-video-conference');
}
// If there's already a discussion assigned to it, do not create a new one
if (call.discussionRid) {
return;
}
// If the call provider does not explicitly support persistent chat, do not create discussions
if (!videoConfProviders.getProviderCapabilities(call.providerName)?.persistentChat) {
return;
}
const name = settings.get<string>('VideoConf_Persistent_Chat_Discussion_Name') || i18n.t('Conference Call Chat History');
const displayName = `${name} - ${new Date().toISOString().substring(0, 10)}`;
await this.createDiscussionForConference(displayName, call, createdBy);
}
private async getRoomForDiscussion(
baseRoom: IRoom['_id'],
childRoomIds: IRoom['_id'][] = [],
): Promise<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid'>>(baseRoom, {
projection: { t: 1, teamId: 1, prid: 1 },
});
if (!room) {
throw new Error('invalid-room');
}
if (room.prid) {
if (childRoomIds.includes(room.prid)) {
throw new Error('Room has circular reference.');
}
return this.getRoomForDiscussion(room.prid, [...childRoomIds, room._id]);
}
return room;
}
private async createDiscussionForConference(
name: string,
call: AtLeast<VideoConference, '_id' | 'rid' | 'createdBy'>,
createdBy?: IUser,
): Promise<void> {
const room = await this.getRoomForDiscussion(call.rid);
const type = await roomCoordinator.getRoomDirectives(room.t).getDiscussionType(room);
const user = call.createdBy._id === createdBy?._id ? createdBy : await Users.findOneById(call.createdBy._id);
if (!user) {
throw new Error('invalid-user');
}
const discussion = await createRoom(
type,
Random.id(),
user,
[],
false,
false,
{
fname: name,
prid: room._id,
encrypted: false,
},
{
creator: user._id,
},
);
return this.assignDiscussionToConference(call._id, discussion._id);
}
public async assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise<void> {
// Ensures the specified rid is a valid room
const room = rid ? await Rooms.findOneById<Pick<IRoom, '_id' | 'prid'>>(rid, { projection: { prid: 1 } }) : null;
if (rid && !room) {
throw new Error('invalid-room-id');
}
const call = await VideoConferenceModel.findOneById(callId, { projection: { users: 1, messages: 1 } });
if (!call) {
return;
}
if (rid === undefined) {
await VideoConferenceModel.unsetDiscussionRidById(callId);
} else {
await VideoConferenceModel.setDiscussionRidById(callId, rid);
}
if (room) {
await Promise.all(call.users.map(({ _id }) => this.addUserToDiscussion(room._id, _id)));
}
}
private async addUserToDiscussion(rid: IRoom['_id'], uid: IUser['_id']): Promise<void> {
try {
await Room.addUserToRoom(rid, { _id: uid }, undefined, { skipAlertSound: true });
} catch (error) {
// Ignore any errors here so that the subscription doesn't block the user from participating in the conference.
logger.error({
name: 'Error trying to subscribe user to discussion',
error,
rid,
uid,
});
}
}
}

@ -1,6 +1,6 @@
import type { Path } from '@rocket.chat/rest-typings';
export const APP_URL = 'https://github.com/RocketChat/Apps.RocketChat.Tester/blob/master/dist/appsrocketchattester_0.0.5.zip?raw=true';
export const APP_URL = 'https://github.com/RocketChat/Apps.RocketChat.Tester/raw/master/dist/appsrocketchattester_0.1.1.zip?raw=true';
export const APP_NAME = 'Apps.RocketChat.Tester';
type PathWithoutPrefix<TPath> = TPath extends `/apps${infer U}` ? U : never;

@ -100,9 +100,10 @@ describe('Apps - Installation', () => {
.expect(200)
.expect((res) => {
expect(res.body).to.have.a.property('success', true);
expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(2);
expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(3);
expect(res.body.data[0]).to.be.an('object').with.a.property('key').equal('test');
expect(res.body.data[1]).to.be.an('object').with.a.property('key').equal('unconfigured');
expect(res.body.data[1]).to.be.an('object').with.a.property('key').equal('persistentchat');
expect(res.body.data[2]).to.be.an('object').with.a.property('key').equal('unconfigured');
})
.end(done);
});

@ -69,6 +69,18 @@ describe('Apps - Video Conferences', () => {
before(async () => {
await cleanupApps();
await installTestApp();
await updateSetting('Discussion_enabled', true);
});
after(async () => {
await updateSetting('VideoConf_Default_Provider', '');
await updateSetting('Discussion_enabled', true);
if (!process.env.IS_EE) {
return;
}
await updateSetting('VideoConf_Enable_Persistent_Chat', false);
await updateSetting('VideoConf_Persistent_Chat_Discussion_Name', 'Conference Call Chat History');
});
describe('[/video-conference.capabilities]', () => {
@ -125,6 +137,7 @@ describe('Apps - Video Conferences', () => {
expect(res.body.capabilities).to.have.a.property('mic').equal(true);
expect(res.body.capabilities).to.have.a.property('cam').equal(false);
expect(res.body.capabilities).to.have.a.property('title').equal(true);
expect(res.body.capabilities).to.have.a.property('persistentChat').equal(false);
});
});
});
@ -233,6 +246,82 @@ describe('Apps - Video Conferences', () => {
expect(res.body.data).to.have.a.property('callId').that.is.a('string');
});
});
it('should start a call successfully when using a provider that supports persistent chat', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
await request
.post(api('video-conference.start'))
.set(credentials)
.send({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body.data).to.be.an('object');
expect(res.body.data).to.have.a.property('providerName').equal('persistentchat');
expect(res.body.data).to.have.a.property('type').equal('videoconference');
expect(res.body.data).to.have.a.property('callId').that.is.a('string');
});
});
it('should start a call successfully when using a provider that supports persistent chat with the feature disabled', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('VideoConf_Enable_Persistent_Chat', false);
await request
.post(api('video-conference.start'))
.set(credentials)
.send({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body.data).to.be.an('object');
expect(res.body.data).to.have.a.property('providerName').equal('persistentchat');
expect(res.body.data).to.have.a.property('type').equal('videoconference');
expect(res.body.data).to.have.a.property('callId').that.is.a('string');
});
});
it('should start a call successfully when using a provider that supports persistent chat with discussions disabled', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
await updateSetting('Discussion_enabled', false);
await request
.post(api('video-conference.start'))
.set(credentials)
.send({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body.data).to.be.an('object');
expect(res.body.data).to.have.a.property('providerName').equal('persistentchat');
expect(res.body.data).to.have.a.property('type').equal('videoconference');
expect(res.body.data).to.have.a.property('callId').that.is.a('string');
});
});
});
describe('[/video-conference.join]', () => {
@ -323,55 +412,398 @@ describe('Apps - Video Conferences', () => {
});
});
describe('[/video-conference.list]', () => {
let callId1: string | undefined;
let callId2: string | undefined;
describe('[video-conference.start + video-conference.info]', () => {
describe('[Test provider with the persistent chat feature disabled]', () => {
let callId: string | undefined;
before(async () => {
await updateSetting('VideoConf_Default_Provider', 'test');
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
before(async () => {
await updateSetting('VideoConf_Default_Provider', 'test');
await updateSetting('Discussion_enabled', true);
if (process.env.IS_EE) {
await updateSetting('VideoConf_Enable_Persistent_Chat', false);
}
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId = res.body.data.callId;
});
callId1 = res.body.data.callId;
const res2 = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
it('should load the video conference data successfully', async () => {
await request
.get(api('video-conference.info'))
.set(credentials)
.query({
callId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('providerName').equal('test');
expect(res.body).to.not.have.a.property('providerData');
expect(res.body).to.have.a.property('_id').equal(callId);
expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}`);
expect(res.body).to.have.a.property('type').equal('videoconference');
expect(res.body).to.have.a.property('rid').equal(roomId);
expect(res.body).to.have.a.property('users').that.is.an('array').with.lengthOf(0);
expect(res.body).to.have.a.property('status').equal(1);
expect(res.body).to.have.a.property('title').equal(roomName);
expect(res.body).to.have.a.property('messages').that.is.an('object');
expect(res.body.messages).to.have.a.property('started').that.is.a('string');
expect(res.body).to.have.a.property('createdBy').that.is.an('object');
expect(res.body.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']);
expect(res.body.createdBy).to.have.a.property('username').equal(adminUsername);
expect(res.body).to.not.have.a.property('discussionRid');
});
});
});
describe('[Test provider with the persistent chat feature enabled]', () => {
let callId: string | undefined;
before(async () => {
if (!process.env.IS_EE) {
return;
}
callId2 = res2.body.data.callId;
await updateSetting('VideoConf_Default_Provider', 'test');
await updateSetting('Discussion_enabled', true);
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId = res.body.data.callId;
});
it('should load the video conference data successfully', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await request
.get(api('video-conference.info'))
.set(credentials)
.query({
callId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('providerName').equal('test');
expect(res.body).to.not.have.a.property('providerData');
expect(res.body).to.have.a.property('_id').equal(callId);
expect(res.body).to.have.a.property('url').equal(`test/videoconference/${callId}/${roomName}`);
expect(res.body).to.have.a.property('type').equal('videoconference');
expect(res.body).to.have.a.property('rid').equal(roomId);
expect(res.body).to.have.a.property('users').that.is.an('array').with.lengthOf(0);
expect(res.body).to.have.a.property('status').equal(1);
expect(res.body).to.have.a.property('title').equal(roomName);
expect(res.body).to.have.a.property('messages').that.is.an('object');
expect(res.body.messages).to.have.a.property('started').that.is.a('string');
expect(res.body).to.have.a.property('createdBy').that.is.an('object');
expect(res.body.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']);
expect(res.body.createdBy).to.have.a.property('username').equal(adminUsername);
expect(res.body).to.not.have.a.property('discussionRid');
});
});
});
it('should load the list of video conferences sorted by new', async () => {
await request
.get(api('video-conference.list'))
.set(credentials)
.query({
describe('[Persistent Chat provider with the persistent chat feature enabled]', () => {
let callId: string | undefined;
let discussionRid: string | undefined;
before(async () => {
if (!process.env.IS_EE) {
return;
}
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('Discussion_enabled', true);
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
await updateSetting('VideoConf_Persistent_Chat_Discussion_Name', 'Chat History');
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId = res.body.data.callId;
});
it('should include a discussion room id on the response', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await request
.get(api('video-conference.info'))
.set(credentials)
.query({
callId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('providerName').equal('persistentchat');
expect(res.body).to.not.have.a.property('providerData');
expect(res.body).to.have.a.property('_id').equal(callId);
expect(res.body).to.have.a.property('discussionRid').that.is.a('string');
discussionRid = res.body.discussionRid;
expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/${discussionRid}`);
});
});
it('should have created the discussion room using the configured name', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await request
.get(api('rooms.info'))
.set(credentials)
.query({
roomId: discussionRid,
})
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('room').and.to.be.an('object');
expect(res.body.room).to.have.a.property('_id').equal(discussionRid);
expect(res.body.room)
.to.have.a.property('fname')
.that.is.a('string')
.that.satisfies((msg: string) => msg.startsWith('Chat History'));
});
});
});
describe('[Persistent Chat provider with the persistent chat feature disabled]', () => {
let callId: string | undefined;
before(async () => {
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('Discussion_enabled', true);
if (process.env.IS_EE) {
await updateSetting('VideoConf_Enable_Persistent_Chat', false);
}
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId = res.body.data.callId;
});
it('should not include a discussion room id on the response', async () => {
await request
.get(api('video-conference.info'))
.set(credentials)
.query({
callId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('providerName').equal('persistentchat');
expect(res.body).to.not.have.a.property('providerData');
expect(res.body).to.have.a.property('_id').equal(callId);
expect(res.body).to.not.have.a.property('discussionRid');
expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/none`);
});
});
});
describe('[Persistent Chat provider with the persistent chat feature enabled but discussions disabled]', () => {
let callId: string | undefined;
before(async () => {
if (!process.env.IS_EE) {
return;
}
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('Discussion_enabled', false);
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId = res.body.data.callId;
});
it('should not include a discussion room id on the response', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await request
.get(api('video-conference.info'))
.set(credentials)
.query({
callId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('providerName').equal('persistentchat');
expect(res.body).to.not.have.a.property('providerData');
expect(res.body).to.have.a.property('_id').equal(callId);
expect(res.body).to.not.have.a.property('discussionRid');
expect(res.body).to.have.a.property('url').equal(`pchat/videoconference/${callId}/none`);
});
});
});
});
describe('[/video-conference.list]', () => {
describe('[Test provider]', () => {
let callId1: string | undefined;
let callId2: string | undefined;
before(async () => {
await updateSetting('VideoConf_Default_Provider', 'test');
await updateSetting('Discussion_enabled', true);
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId1 = res.body.data.callId;
const res2 = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('count').that.is.greaterThanOrEqual(2);
expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(res.body.count);
const call2 = res.body.data[0];
const call1 = res.body.data[1];
expect(call1).to.have.a.property('_id').equal(callId1);
expect(call1).to.have.a.property('url').equal(`test/videoconference/${callId1}/${roomName}`);
expect(call1).to.have.a.property('type').equal('videoconference');
expect(call1).to.have.a.property('rid').equal(roomId);
expect(call1).to.have.a.property('users').that.is.an('array').with.lengthOf(0);
expect(call1).to.have.a.property('status').equal(1);
expect(call1).to.have.a.property('title').equal(roomName);
expect(call1).to.have.a.property('messages').that.is.an('object');
expect(call1.messages).to.have.a.property('started').that.is.a('string');
expect(call1).to.have.a.property('createdBy').that.is.an('object');
expect(call1.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']);
expect(call1.createdBy).to.have.a.property('username').equal(adminUsername);
expect(call2).to.have.a.property('_id').equal(callId2);
});
callId2 = res2.body.data.callId;
});
it('should load the list of video conferences sorted by new', async () => {
await request
.get(api('video-conference.list'))
.set(credentials)
.query({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('count').that.is.greaterThanOrEqual(2);
expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(res.body.count);
const call2 = res.body.data[0];
const call1 = res.body.data[1];
expect(call1).to.have.a.property('_id').equal(callId1);
expect(call1).to.have.a.property('url').equal(`test/videoconference/${callId1}/${roomName}`);
expect(call1).to.have.a.property('type').equal('videoconference');
expect(call1).to.have.a.property('rid').equal(roomId);
expect(call1).to.have.a.property('users').that.is.an('array').with.lengthOf(0);
expect(call1).to.have.a.property('status').equal(1);
expect(call1).to.have.a.property('title').equal(roomName);
expect(call1).to.have.a.property('messages').that.is.an('object');
expect(call1.messages).to.have.a.property('started').that.is.a('string');
expect(call1).to.have.a.property('createdBy').that.is.an('object');
expect(call1.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']);
expect(call1.createdBy).to.have.a.property('username').equal(adminUsername);
expect(call1).to.not.have.a.property('discussionRid');
expect(call2).to.have.a.property('_id').equal(callId2);
});
});
});
describe('[Persistent Chat Provider]', () => {
let callId1: string | undefined;
let callId2: string | undefined;
let callId3: string | undefined;
let callId4: string | undefined;
before(async () => {
if (!process.env.IS_EE) {
return;
}
await updateSetting('Discussion_enabled', true);
await updateSetting('VideoConf_Default_Provider', 'test');
const res = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId1 = res.body.data.callId;
await updateSetting('VideoConf_Default_Provider', 'persistentchat');
await updateSetting('VideoConf_Enable_Persistent_Chat', false);
const res2 = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId2 = res2.body.data.callId;
await updateSetting('VideoConf_Enable_Persistent_Chat', true);
const res3 = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId3 = res3.body.data.callId;
await updateSetting('Discussion_enabled', false);
const res4 = await request.post(api('video-conference.start')).set(credentials).send({
roomId,
});
callId4 = res4.body.data.callId;
});
it('should load the list of video conferences sorted by new', async function () {
if (!process.env.IS_EE) {
this.skip();
return;
}
await request
.get(api('video-conference.list'))
.set(credentials)
.query({
roomId,
})
.expect(200)
.expect((res: Response) => {
expect(res.body.success).to.be.equal(true);
expect(res.body).to.have.a.property('count').that.is.greaterThanOrEqual(4);
expect(res.body).to.have.a.property('data').that.is.an('array').with.lengthOf(res.body.count);
const call4 = res.body.data[0];
const call3 = res.body.data[1];
const call2 = res.body.data[2];
const call1 = res.body.data[3];
expect(call1).to.have.a.property('_id').equal(callId1);
expect(call1).to.have.a.property('url').equal(`test/videoconference/${callId1}/${roomName}`);
expect(call1).to.have.a.property('type').equal('videoconference');
expect(call1).to.have.a.property('rid').equal(roomId);
expect(call1).to.have.a.property('users').that.is.an('array').with.lengthOf(0);
expect(call1).to.have.a.property('status').equal(1);
expect(call1).to.have.a.property('title').equal(roomName);
expect(call1).to.have.a.property('messages').that.is.an('object');
expect(call1.messages).to.have.a.property('started').that.is.a('string');
expect(call1).to.have.a.property('createdBy').that.is.an('object');
expect(call1.createdBy).to.have.a.property('_id').equal(credentials['X-User-Id']);
expect(call1.createdBy).to.have.a.property('username').equal(adminUsername);
expect(call1).to.not.have.a.property('discussionRid');
expect(call2).to.have.a.property('_id').equal(callId2);
expect(call2).to.not.have.a.property('discussionRid');
expect(call3).to.have.a.property('_id').equal(callId3);
expect(call3).to.have.a.property('discussionRid').that.is.a('string');
expect(call4).to.have.a.property('_id').equal(callId4);
expect(call4).to.not.have.a.property('discussionRid');
});
});
});
});
});

@ -32,9 +32,12 @@ export interface IRoomService {
createDirectMessageWithMultipleUsers(members: string[], creatorId: string): Promise<{ rid: string }>;
addUserToRoom(
roomId: string,
user: Pick<IUser, '_id' | 'username'> | string,
user: Pick<IUser, '_id'> | string,
inviter?: Pick<IUser, '_id' | 'username'>,
silenced?: boolean,
options?: {
skipSystemMessage?: boolean;
skipAlertSound?: boolean;
},
): Promise<boolean | undefined>;
removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick<IUser, '_id' | 'username'> }): Promise<void>;
getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise<string>;

@ -40,4 +40,5 @@ export interface IVideoConfService {
caller: IUser['_id'],
params: { callId: VideoConference['_id']; uid: IUser['_id']; rid: IRoom['_id'] },
): Promise<boolean>;
assignDiscussionToConference(callId: VideoConference['_id'], rid: IRoom['_id'] | undefined): Promise<void>;
}

@ -56,6 +56,7 @@ export interface IVideoConference extends IRocketChatRecord {
providerData?: Record<string, any>;
ringing?: boolean;
discussionRid?: IRoom['_id'];
}
export interface IDirectVideoConference extends IVideoConference {

@ -2,4 +2,5 @@ export type VideoConferenceCapabilities = {
mic?: boolean;
cam?: boolean;
title?: boolean;
persistentChat?: boolean;
};

@ -1,4 +1,8 @@
import { useTranslation, useUserId } from '@rocket.chat/ui-contexts';
import {
useGoToRoom,
useTranslation,
useUserId,
} from '@rocket.chat/ui-contexts';
import type * as UiKit from '@rocket.chat/ui-kit';
import {
VideoConfMessageSkeleton,
@ -33,6 +37,7 @@ const VideoConferenceBlock = ({
const { callId, appId = 'videoconf-core' } = block;
const surfaceType = useSurfaceType();
const userId = useUserId();
const goToRoom = useGoToRoom();
const { action, viewId = undefined, rid } = useContext(UiKitContext);
@ -85,6 +90,12 @@ const VideoConferenceBlock = ({
);
};
const openDiscussion: MouseEventHandler<HTMLButtonElement> = (_e) => {
if (data.discussionRid) {
goToRoom(data.discussionRid);
}
};
if (result.isLoading || result.isError) {
// TODO: error handling
return <VideoConfMessageSkeleton />;
@ -93,6 +104,19 @@ const VideoConferenceBlock = ({
const { data } = result;
const isUserCaller = data.createdBy._id === userId;
const actions = (
<VideoConfMessageActions>
{data.discussionRid && (
<VideoConfMessageAction
icon='discussion'
title={t('Join_discussion')}
onClick={openDiscussion}
/>
)}
<VideoConfMessageAction icon='info' onClick={openCallInfo} />
</VideoConfMessageActions>
);
if ('endedAt' in data) {
return (
<VideoConfMessage>
@ -101,9 +125,7 @@ const VideoConferenceBlock = ({
<VideoConfMessageIcon />
<VideoConfMessageText>{t('Call_ended')}</VideoConfMessageText>
</VideoConfMessageContent>
<VideoConfMessageActions>
<VideoConfMessageAction icon='info' onClick={openCallInfo} />
</VideoConfMessageActions>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
{data.type === 'direct' && (
@ -146,9 +168,7 @@ const VideoConferenceBlock = ({
<VideoConfMessageIcon variant='incoming' />
<VideoConfMessageText>{t('Calling')}</VideoConfMessageText>
</VideoConfMessageContent>
<VideoConfMessageActions>
<VideoConfMessageAction icon='info' onClick={openCallInfo} />
</VideoConfMessageActions>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageFooterText>
@ -166,9 +186,7 @@ const VideoConferenceBlock = ({
<VideoConfMessageIcon variant='outgoing' />
<VideoConfMessageText>{t('Call_ongoing')}</VideoConfMessageText>
</VideoConfMessageContent>
<VideoConfMessageActions>
<VideoConfMessageAction icon='info' onClick={openCallInfo} />
</VideoConfMessageActions>
{actions}
</VideoConfMessageRow>
<VideoConfMessageFooter>
<VideoConfMessageButton primary onClick={joinHandler}>

@ -2862,6 +2862,7 @@
"Join_Chat": "Join Chat",
"Join_conference": "Join conference",
"Join_default_channels": "Join default channels",
"Join_discussion": "Join discussion",
"Join_the_Community": "Join the Community",
"Join_the_given_channel": "Join the given channel",
"Join_rooms": "Join rooms",
@ -5755,6 +5756,10 @@
"VideoConf_Enable_Groups": "Enable in private channels",
"VideoConf_Enable_DMs": "Enable in direct messages",
"VideoConf_Enable_Teams": "Enable in teams",
"VideoConf_Enable_Persistent_Chat": "Enable Persistent Chat",
"VideoConf_Enable_Persistent_Chat_description": "When persistent chat is enabled, Rocket.Chat will create a discussion every time a conference call is initiated. The provider app is responsible for sending the chat messages to this discussion.",
"VideoConf_Enable_Persistent_Chat_Alert": "Persistent Chat will not work if discussions are disabled on the workspace. It will also not work if the provider app being used do not explicitly support this feature.",
"VideoConf_Persistent_Chat_Discussion_Name": "Persistent Chat Discussion Name",
"VideoConf_Mobile_Ringing": "Enable mobile ringing",
"VideoConf_Mobile_Ringing_Description": "When enabled, direct calls to mobile users will ring their device as a phone call.",
"VideoConf_Mobile_Ringing_Alert": "This feature is currently in an experimental stage and may not yet be fully supported by the mobile app. When enabled it will send additional Push Notifications to users.",

@ -61,4 +61,10 @@ export interface IVideoConferenceModel extends IBaseModel<VideoConference> {
updateUserReferences(userId: IUser['_id'], username: IUser['username'], name: IUser['name']): Promise<void>;
increaseAnonymousCount(callId: IGroupVideoConference['_id']): Promise<void>;
unsetDiscussionRidById(callId: string): Promise<void>;
setDiscussionRidById(callId: string, discussionRid: IRoom['_id']): Promise<void>;
unsetDiscussionRid(discussionRid: IRoom['_id']): Promise<void>;
}

Loading…
Cancel
Save