Ver código fonte

feat(breakout-rooms) introduce breakout rooms

They are companion rooms created in a separate MUC. The room relationship is
maintained by a Prosody plugin.

All signalling happens through the breakout rooms MUC component.
Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>
dev1
Werner Fleischer 4 anos atrás
pai
commit
bdfbb82087

+ 11
- 2
JitsiConference.js Ver arquivo

@@ -122,7 +122,7 @@ const JINGLE_SI_TIMEOUT = 5000;
122 122
  *       and so on...
123 123
  */
124 124
 export default function JitsiConference(options) {
125
-    if (!options.name || options.name.toLowerCase() !== options.name) {
125
+    if (!options.name || options.name.toLowerCase() !== options.name.toString()) {
126 126
         const errmsg
127 127
             = 'Invalid conference name (no conference name passed or it '
128 128
                 + 'contains invalid characters like capital letters)!';
@@ -765,7 +765,7 @@ JitsiConference.prototype._sendBridgeVideoTypeMessage = function(localtrack) {
765 765
  * Returns name of this conference.
766 766
  */
767 767
 JitsiConference.prototype.getName = function() {
768
-    return this.options.name;
768
+    return this.options.name.toString();
769 769
 };
770 770
 
771 771
 /**
@@ -4004,3 +4004,12 @@ JitsiConference.prototype.avModerationReject = function(mediaType, id) {
4004 4004
             this.room && this.isModerator() ? 'wrong media type passed' : ''}`);
4005 4005
     }
4006 4006
 };
4007
+
4008
+/**
4009
+ * Returns the breakout rooms manager object.
4010
+ *
4011
+ * @returns {Object} the breakout rooms manager.
4012
+ */
4013
+JitsiConference.prototype.getBreakoutRooms = function() {
4014
+    return this.room?.getBreakoutRooms();
4015
+};

+ 6
- 0
JitsiConferenceEventManager.js Ver arquivo

@@ -482,6 +482,12 @@ JitsiConferenceEventManager.prototype.setupChatRoomListeners = function() {
482 482
                 conference.statistics.sendAddIceCandidateFailed(e, pc);
483 483
             });
484 484
     }
485
+
486
+    // Breakout rooms.
487
+    this.chatRoomForwarder.forward(XMPPEvents.BREAKOUT_ROOMS_MOVE_TO_ROOM,
488
+        JitsiConferenceEvents.BREAKOUT_ROOMS_MOVE_TO_ROOM);
489
+    this.chatRoomForwarder.forward(XMPPEvents.BREAKOUT_ROOMS_UPDATED,
490
+        JitsiConferenceEvents.BREAKOUT_ROOMS_UPDATED);
485 491
 };
486 492
 
487 493
 /**

+ 10
- 0
JitsiConferenceEvents.js Ver arquivo

@@ -432,3 +432,13 @@ export const AV_MODERATION_PARTICIPANT_REJECTED = 'conference.av_moderation.part
432 432
  * A new facial expression is added with its duration for a participant
433 433
  */
434 434
 export const FACIAL_EXPRESSION_ADDED = 'conference.facial_expression.added';
435
+
436
+/**
437
+ * Event fired when a participant is requested to join a given (breakout) room.
438
+ */
439
+export const BREAKOUT_ROOMS_MOVE_TO_ROOM = 'conference.breakout-rooms.move-to-room';
440
+
441
+/**
442
+ * Event fired when the breakout rooms data was updated.
443
+ */
444
+export const BREAKOUT_ROOMS_UPDATED = 'conference.breakout-rooms.updated';

+ 185
- 0
modules/xmpp/BreakoutRooms.js Ver arquivo

@@ -0,0 +1,185 @@
1
+import { getLogger } from '@jitsi/logger';
2
+import { $msg } from 'strophe.js';
3
+
4
+import XMPPEvents from '../../service/xmpp/XMPPEvents';
5
+
6
+const FEATURE_KEY = 'features/breakout-rooms';
7
+const BREAKOUT_ROOM_ACTIONS = {
8
+    ADD: `${FEATURE_KEY}/add`,
9
+    REMOVE: `${FEATURE_KEY}/remove`,
10
+    MOVE_TO_ROOM: `${FEATURE_KEY}/move-to-room`
11
+};
12
+const BREAKOUT_ROOM_EVENTS = {
13
+    MOVE_TO_ROOM: `${FEATURE_KEY}/move-to-room`,
14
+    UPDATE: `${FEATURE_KEY}/update`
15
+};
16
+
17
+const logger = getLogger(__filename);
18
+
19
+/**
20
+ * Helper class for handling breakout rooms.
21
+ */
22
+export default class BreakoutRooms {
23
+
24
+    /**
25
+     * Constructs lobby room.
26
+     *
27
+     * @param {ChatRoom} room the room we are in.
28
+     */
29
+    constructor(room) {
30
+        this.room = room;
31
+
32
+        this.room.xmpp.addListener(XMPPEvents.BREAKOUT_ROOMS_EVENT, this._handleMessages.bind(this));
33
+
34
+        this._rooms = {};
35
+    }
36
+
37
+    /**
38
+     * Creates a breakout room with the given subject.
39
+     *
40
+     * @param {string} subject - A subject for the breakout room.
41
+     */
42
+    createBreakoutRoom(subject) {
43
+        if (!this.isSupported() || !this.room.isModerator()) {
44
+            logger.error(`Cannot create breakout room - supported:${this.isSupported()}, 
45
+                moderator:${this.room.isModerator()}`);
46
+
47
+            return;
48
+        }
49
+
50
+        const message = {
51
+            type: BREAKOUT_ROOM_ACTIONS.ADD,
52
+            subject
53
+        };
54
+
55
+        this._sendMessage(message);
56
+    }
57
+
58
+    /**
59
+     * Removes a breakout room.
60
+     *
61
+     * @param {string} breakoutRoomJid - JID of the room to be removed.
62
+     */
63
+    removeBreakoutRoom(breakoutRoomJid) {
64
+        if (!this.isSupported() || !this.room.isModerator()) {
65
+            logger.error(`Cannot remove breakout room - supported:${this.isSupported()}, 
66
+                moderator:${this.room.isModerator()}`);
67
+
68
+            return;
69
+        }
70
+
71
+        const message = {
72
+            type: BREAKOUT_ROOM_ACTIONS.REMOVE,
73
+            breakoutRoomJid
74
+        };
75
+
76
+        this._sendMessage(message);
77
+    }
78
+
79
+    /**
80
+     * Sends the given participant to the given room.
81
+     *
82
+     * @param {string} participantJid - JID of the participant to be sent to a room.
83
+     * @param {string} roomJid - JID of the target room.
84
+     */
85
+    sendParticipantToRoom(participantJid, roomJid) {
86
+        if (!this.isSupported() || !this.room.isModerator()) {
87
+            logger.error(`Cannot send participant to room - supported:${this.isSupported()}, 
88
+                moderator:${this.room.isModerator()}`);
89
+
90
+            return;
91
+        }
92
+
93
+        const message = {
94
+            type: BREAKOUT_ROOM_ACTIONS.MOVE_TO_ROOM,
95
+            participantJid,
96
+            roomJid
97
+        };
98
+
99
+        this._sendMessage(message);
100
+    }
101
+
102
+    /**
103
+     * Whether Breakout Rooms support is enabled in the backend or not.
104
+     */
105
+    isSupported() {
106
+        return Boolean(this.getComponentAddress());
107
+    }
108
+
109
+    /**
110
+     * Gets the address of the Breakout Rooms XMPP component.
111
+     *
112
+     * @returns The address of the component.
113
+     */
114
+    getComponentAddress() {
115
+        return this.room.xmpp.breakoutRoomsComponentAddress;
116
+    }
117
+
118
+    /**
119
+     * Stores if the current room is a breakout room.
120
+     *
121
+     * @param {boolean} isBreakoutRoom - Whether this room is a breakout room.
122
+     */
123
+    _setIsBreakoutRoom(isBreakoutRoom) {
124
+        this._isBreakoutRoom = isBreakoutRoom;
125
+    }
126
+
127
+    /**
128
+     * Checks whether this room is a breakout room.
129
+     *
130
+     * @returns True if the room is a breakout room, false otherwise.
131
+     */
132
+    isBreakoutRoom() {
133
+        return this._isBreakoutRoom;
134
+    }
135
+
136
+    /**
137
+     * Sets the main room JID associated with this breakout room. Only applies when
138
+     * in a breakout room.
139
+     *
140
+     * @param {string} jid - The main room JID.
141
+     */
142
+    _setMainRoomJid(jid) {
143
+        this._mainRoomJid = jid;
144
+    }
145
+
146
+    /**
147
+     * Gets the main room's JID associated with this breakout room.
148
+     *
149
+     * @returns The main room JID.
150
+     */
151
+    getMainRoomJid() {
152
+        return this._mainRoomJid;
153
+    }
154
+
155
+    /**
156
+     * Handles a message for managing breakout rooms.
157
+     *
158
+     * @param {object} payload - Arbitrary data.
159
+     */
160
+    _handleMessages(payload) {
161
+        switch (payload.event) {
162
+        case BREAKOUT_ROOM_EVENTS.MOVE_TO_ROOM:
163
+            this.room.eventEmitter.emit(XMPPEvents.BREAKOUT_ROOMS_MOVE_TO_ROOM, payload.roomJid);
164
+            break;
165
+        case BREAKOUT_ROOM_EVENTS.UPDATE: {
166
+            this._rooms = payload.rooms;
167
+            this.room.eventEmitter.emit(XMPPEvents.BREAKOUT_ROOMS_UPDATED, payload.rooms);
168
+            break;
169
+        }
170
+        }
171
+    }
172
+
173
+    /**
174
+     * Helper to send a breakout rooms message to the component.
175
+     *
176
+     * @param {Object} message - Command that needs to be sent.
177
+     */
178
+    _sendMessage(message) {
179
+        const msg = $msg({ to: this.getComponentAddress() });
180
+
181
+        msg.c('breakout_rooms', message).up();
182
+
183
+        this.room.xmpp.connection.send(msg);
184
+    }
185
+}

+ 29
- 2
modules/xmpp/ChatRoom.js Ver arquivo

@@ -11,6 +11,7 @@ import GlobalOnErrorHandler from '../util/GlobalOnErrorHandler';
11 11
 import Listenable from '../util/Listenable';
12 12
 
13 13
 import AVModeration from './AVModeration';
14
+import BreakoutRooms from './BreakoutRooms';
14 15
 import Lobby from './Lobby';
15 16
 import XmppConnection from './XmppConnection';
16 17
 import Moderator from './moderator';
@@ -137,6 +138,7 @@ export default class ChatRoom extends Listenable {
137 138
             this.lobby = new Lobby(this);
138 139
         }
139 140
         this.avModeration = new AVModeration(this);
141
+        this.breakoutRooms = new BreakoutRooms(this);
140 142
         this.initPresenceMap(options);
141 143
         this.lastPresences = {};
142 144
         this.phoneNumber = null;
@@ -331,6 +333,19 @@ export default class ChatRoom extends Listenable {
331 333
                 this.lobby.setLobbyRoomJid(lobbyRoomField && lobbyRoomField.length ? lobbyRoomField.text() : undefined);
332 334
             }
333 335
 
336
+            const isBreakoutField
337
+                = $(result).find('>query>x[type="result"]>field[var="muc#roominfo_isbreakout"]>value');
338
+            const isBreakoutRoom = Boolean(isBreakoutField?.text());
339
+
340
+            this.breakoutRooms._setIsBreakoutRoom(isBreakoutRoom);
341
+
342
+            const breakoutMainRoomField
343
+                = $(result).find('>query>x[type="result"]>field[var="muc#roominfo_breakout_main_room"]>value');
344
+
345
+            if (breakoutMainRoomField?.length) {
346
+                this.breakoutRooms._setMainRoomJid(breakoutMainRoomField.text());
347
+            }
348
+
334 349
             if (membersOnly !== this.membersOnlyEnabled) {
335 350
                 this.membersOnlyEnabled = membersOnly;
336 351
                 this.eventEmitter.emit(XMPPEvents.MUC_MEMBERS_ONLY_CHANGED, membersOnly);
@@ -1709,6 +1724,12 @@ export default class ChatRoom extends Listenable {
1709 1724
         return this.avModeration;
1710 1725
     }
1711 1726
 
1727
+    /**
1728
+     * @returns {BreakoutRooms}
1729
+     */
1730
+    getBreakoutRooms() {
1731
+        return this.breakoutRooms;
1732
+    }
1712 1733
 
1713 1734
     /**
1714 1735
      * Returns the phone number for joining the conference.
@@ -1825,7 +1846,11 @@ export default class ChatRoom extends Listenable {
1825 1846
      * rejected.
1826 1847
      */
1827 1848
     leave() {
1828
-        return new Promise((resolve, reject) => {
1849
+        const promises = [];
1850
+
1851
+        this.lobby?.lobbyRoom && promises.push(this.lobby.leave());
1852
+
1853
+        promises.push(new Promise((resolve, reject) => {
1829 1854
             const timeout = setTimeout(() => onMucLeft(true), 5000);
1830 1855
             const eventEmitter = this.eventEmitter;
1831 1856
 
@@ -1848,7 +1873,9 @@ export default class ChatRoom extends Listenable {
1848 1873
             }
1849 1874
             eventEmitter.on(XMPPEvents.MUC_LEFT, onMucLeft);
1850 1875
             this.doLeave();
1851
-        });
1876
+        }));
1877
+
1878
+        return Promise.all(promises);
1852 1879
     }
1853 1880
 }
1854 1881
 

+ 25
- 8
modules/xmpp/Lobby.js Ver arquivo

@@ -84,17 +84,21 @@ export default class Lobby {
84 84
 
85 85
     /**
86 86
      * Leaves the lobby room.
87
-     * @private
87
+     *
88
+     * @returns {Promise}
88 89
      */
89
-    _leaveLobbyRoom() {
90
+    leave() {
90 91
         if (this.lobbyRoom) {
91
-            this.lobbyRoom.leave()
92
+            return this.lobbyRoom.leave()
92 93
                 .then(() => {
93 94
                     this.lobbyRoom = undefined;
94 95
                     logger.info('Lobby room left!');
95 96
                 })
96 97
                 .catch(() => {}); // eslint-disable-line no-empty-function
97 98
         }
99
+
100
+        return Promise.reject(
101
+                new Error('The lobby has already been left'));
98 102
     }
99 103
 
100 104
     /**
@@ -172,6 +176,13 @@ export default class Lobby {
172 176
                         return;
173 177
                     }
174 178
 
179
+                    // Check if the user is a member if any breakout room.
180
+                    for (const room of Object.values(this.mainRoom.getBreakoutRooms()._rooms)) {
181
+                        if (Object.values(room.participants).find(p => p.jid === jid)) {
182
+                            return;
183
+                        }
184
+                    }
185
+
175 186
                     // we emit the new event on the main room so we can propagate
176 187
                     // events to the conference
177 188
                     this.mainRoom.eventEmitter.emit(
@@ -223,10 +234,8 @@ export default class Lobby {
223 234
                 (roomJid, from, txt, invitePassword) => {
224 235
                     logger.debug(`Received approval to join ${roomJid} ${from} ${txt}`);
225 236
                     if (roomJid === this.mainRoom.roomjid) {
226
-                        // we are now allowed let's join and leave lobby
237
+                        // we are now allowed, so let's join
227 238
                         this.mainRoom.join(invitePassword);
228
-
229
-                        this._leaveLobbyRoom();
230 239
                     }
231 240
                 });
232 241
             this.lobbyRoom.addEventListener(
@@ -250,7 +259,7 @@ export default class Lobby {
250 259
             this.mainRoom.addEventListener(
251 260
                 XMPPEvents.MUC_JOINED,
252 261
                 () => {
253
-                    this._leaveLobbyRoom();
262
+                    this.leave();
254 263
                 });
255 264
         }
256 265
 
@@ -301,13 +310,21 @@ export default class Lobby {
301 310
             return;
302 311
         }
303 312
 
313
+        // Get the main room JID. If we are in a breakout room we'll use the main
314
+        // room's lobby.
315
+        let mainRoomJid = this.mainRoom.roomjid;
316
+
317
+        if (this.mainRoom.getBreakoutRooms().isBreakoutRoom()) {
318
+            mainRoomJid = this.mainRoom.getBreakoutRooms().getMainRoomJid();
319
+        }
320
+
304 321
         const memberRoomJid = Object.keys(this.lobbyRoom.members)
305 322
             .find(j => Strophe.getResourceFromJid(j) === id);
306 323
 
307 324
         if (memberRoomJid) {
308 325
             const jid = this.lobbyRoom.members[memberRoomJid].jid;
309 326
             const msgToSend
310
-                = $msg({ to: this.mainRoom.roomjid })
327
+                = $msg({ to: mainRoomJid })
311 328
                     .c('x', { xmlns: 'http://jabber.org/protocol/muc#user' })
312 329
                     .c('invite', { to: jid });
313 330
 

+ 12
- 3
modules/xmpp/xmpp.js Ver arquivo

@@ -453,6 +453,10 @@ export default class XMPP extends Listenable {
453 453
             if (identity.type === 'region') {
454 454
                 this.options.deploymentInfo.region = this.connection.region = identity.name;
455 455
             }
456
+
457
+            if (identity.type === 'breakout_rooms') {
458
+                this.breakoutRoomsComponentAddress = identity.name;
459
+            }
456 460
         });
457 461
 
458 462
         this._maybeSendDeploymentInfoStat(true);
@@ -645,9 +649,11 @@ export default class XMPP extends Listenable {
645 649
      * @returns {Promise} Resolves with an instance of a strophe muc.
646 650
      */
647 651
     createRoom(roomName, options, onCreateResource) {
648
-        // There are cases (when using subdomain) where muc can hold an uppercase part
649
-        let roomjid = `${this.getRoomJid(roomName, options.customDomain)}/`;
652
+        // Support passing the domain in a String object as part of the room name.
653
+        const domain = roomName.domain || options.customDomain;
650 654
 
655
+        // There are cases (when using subdomain) where muc can hold an uppercase part
656
+        let roomjid = `${this.getRoomJid(roomName, domain)}/`;
651 657
         const mucNickname = onCreateResource
652 658
             ? onCreateResource(this.connection.jid, this.authenticatedUser)
653 659
             : RandomUtil.randomHexString(8).toLowerCase();
@@ -979,7 +985,8 @@ export default class XMPP extends Listenable {
979 985
 
980 986
         if (!(from === this.speakerStatsComponentAddress
981 987
             || from === this.conferenceDurationComponentAddress
982
-            || from === this.avModerationComponentAddress)) {
988
+            || from === this.avModerationComponentAddress
989
+            || from === this.breakoutRoomsComponentAddress)) {
983 990
             return true;
984 991
         }
985 992
 
@@ -997,6 +1004,8 @@ export default class XMPP extends Listenable {
997 1004
             this.eventEmitter.emit(XMPPEvents.CONFERENCE_TIMESTAMP_RECEIVED, parsedJson.created_timestamp);
998 1005
         } else if (parsedJson[JITSI_MEET_MUC_TYPE] === 'av_moderation') {
999 1006
             this.eventEmitter.emit(XMPPEvents.AV_MODERATION_RECEIVED, parsedJson);
1007
+        } else if (parsedJson[JITSI_MEET_MUC_TYPE] === 'breakout_rooms') {
1008
+            this.eventEmitter.emit(XMPPEvents.BREAKOUT_ROOMS_EVENT, parsedJson);
1000 1009
         }
1001 1010
 
1002 1011
         return true;

+ 15
- 0
service/xmpp/XMPPEvents.js Ver arquivo

@@ -281,6 +281,21 @@ const XMPPEvents = {
281 281
      */
282 282
     AV_MODERATION_PARTICIPANT_REJECTED: 'xmpp.av_moderation.participant.rejected',
283 283
 
284
+    /**
285
+     * Event fired when a participant is requested to join a given (breakout) room.
286
+     */
287
+    BREAKOUT_ROOMS_MOVE_TO_ROOM: 'xmpp.breakout-rooms.move-to-room',
288
+
289
+    /**
290
+     * Event fired when we receive a message for breakout rooms.
291
+     */
292
+    BREAKOUT_ROOMS_EVENT: 'xmpp.breakout-rooms.event',
293
+
294
+    /**
295
+     * Event fired when the breakout rooms data was updated.
296
+     */
297
+    BREAKOUT_ROOMS_UPDATED: 'xmpp.breakout-rooms.updated',
298
+
284 299
     // Designates an event indicating that we should join the conference with
285 300
     // audio and/or video muted.
286 301
     START_MUTED_FROM_FOCUS: 'xmpp.start_muted_from_focus',

Carregando…
Cancelar
Salvar