Browse Source

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>
tags/v0.0.2
Werner Fleischer 4 years ago
parent
commit
bdfbb82087

+ 11
- 2
JitsiConference.js View File

122
  *       and so on...
122
  *       and so on...
123
  */
123
  */
124
 export default function JitsiConference(options) {
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
         const errmsg
126
         const errmsg
127
             = 'Invalid conference name (no conference name passed or it '
127
             = 'Invalid conference name (no conference name passed or it '
128
                 + 'contains invalid characters like capital letters)!';
128
                 + 'contains invalid characters like capital letters)!';
765
  * Returns name of this conference.
765
  * Returns name of this conference.
766
  */
766
  */
767
 JitsiConference.prototype.getName = function() {
767
 JitsiConference.prototype.getName = function() {
768
-    return this.options.name;
768
+    return this.options.name.toString();
769
 };
769
 };
770
 
770
 
771
 /**
771
 /**
4004
             this.room && this.isModerator() ? 'wrong media type passed' : ''}`);
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 View File

482
                 conference.statistics.sendAddIceCandidateFailed(e, pc);
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 View File

432
  * A new facial expression is added with its duration for a participant
432
  * A new facial expression is added with its duration for a participant
433
  */
433
  */
434
 export const FACIAL_EXPRESSION_ADDED = 'conference.facial_expression.added';
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 View File

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 View File

11
 import Listenable from '../util/Listenable';
11
 import Listenable from '../util/Listenable';
12
 
12
 
13
 import AVModeration from './AVModeration';
13
 import AVModeration from './AVModeration';
14
+import BreakoutRooms from './BreakoutRooms';
14
 import Lobby from './Lobby';
15
 import Lobby from './Lobby';
15
 import XmppConnection from './XmppConnection';
16
 import XmppConnection from './XmppConnection';
16
 import Moderator from './moderator';
17
 import Moderator from './moderator';
137
             this.lobby = new Lobby(this);
138
             this.lobby = new Lobby(this);
138
         }
139
         }
139
         this.avModeration = new AVModeration(this);
140
         this.avModeration = new AVModeration(this);
141
+        this.breakoutRooms = new BreakoutRooms(this);
140
         this.initPresenceMap(options);
142
         this.initPresenceMap(options);
141
         this.lastPresences = {};
143
         this.lastPresences = {};
142
         this.phoneNumber = null;
144
         this.phoneNumber = null;
331
                 this.lobby.setLobbyRoomJid(lobbyRoomField && lobbyRoomField.length ? lobbyRoomField.text() : undefined);
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
             if (membersOnly !== this.membersOnlyEnabled) {
349
             if (membersOnly !== this.membersOnlyEnabled) {
335
                 this.membersOnlyEnabled = membersOnly;
350
                 this.membersOnlyEnabled = membersOnly;
336
                 this.eventEmitter.emit(XMPPEvents.MUC_MEMBERS_ONLY_CHANGED, membersOnly);
351
                 this.eventEmitter.emit(XMPPEvents.MUC_MEMBERS_ONLY_CHANGED, membersOnly);
1709
         return this.avModeration;
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
      * Returns the phone number for joining the conference.
1735
      * Returns the phone number for joining the conference.
1825
      * rejected.
1846
      * rejected.
1826
      */
1847
      */
1827
     leave() {
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
             const timeout = setTimeout(() => onMucLeft(true), 5000);
1854
             const timeout = setTimeout(() => onMucLeft(true), 5000);
1830
             const eventEmitter = this.eventEmitter;
1855
             const eventEmitter = this.eventEmitter;
1831
 
1856
 
1848
             }
1873
             }
1849
             eventEmitter.on(XMPPEvents.MUC_LEFT, onMucLeft);
1874
             eventEmitter.on(XMPPEvents.MUC_LEFT, onMucLeft);
1850
             this.doLeave();
1875
             this.doLeave();
1851
-        });
1876
+        }));
1877
+
1878
+        return Promise.all(promises);
1852
     }
1879
     }
1853
 }
1880
 }
1854
 
1881
 

+ 25
- 8
modules/xmpp/Lobby.js View File

84
 
84
 
85
     /**
85
     /**
86
      * Leaves the lobby room.
86
      * Leaves the lobby room.
87
-     * @private
87
+     *
88
+     * @returns {Promise}
88
      */
89
      */
89
-    _leaveLobbyRoom() {
90
+    leave() {
90
         if (this.lobbyRoom) {
91
         if (this.lobbyRoom) {
91
-            this.lobbyRoom.leave()
92
+            return this.lobbyRoom.leave()
92
                 .then(() => {
93
                 .then(() => {
93
                     this.lobbyRoom = undefined;
94
                     this.lobbyRoom = undefined;
94
                     logger.info('Lobby room left!');
95
                     logger.info('Lobby room left!');
95
                 })
96
                 })
96
                 .catch(() => {}); // eslint-disable-line no-empty-function
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
                         return;
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
                     // we emit the new event on the main room so we can propagate
186
                     // we emit the new event on the main room so we can propagate
176
                     // events to the conference
187
                     // events to the conference
177
                     this.mainRoom.eventEmitter.emit(
188
                     this.mainRoom.eventEmitter.emit(
223
                 (roomJid, from, txt, invitePassword) => {
234
                 (roomJid, from, txt, invitePassword) => {
224
                     logger.debug(`Received approval to join ${roomJid} ${from} ${txt}`);
235
                     logger.debug(`Received approval to join ${roomJid} ${from} ${txt}`);
225
                     if (roomJid === this.mainRoom.roomjid) {
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
                         this.mainRoom.join(invitePassword);
238
                         this.mainRoom.join(invitePassword);
228
-
229
-                        this._leaveLobbyRoom();
230
                     }
239
                     }
231
                 });
240
                 });
232
             this.lobbyRoom.addEventListener(
241
             this.lobbyRoom.addEventListener(
250
             this.mainRoom.addEventListener(
259
             this.mainRoom.addEventListener(
251
                 XMPPEvents.MUC_JOINED,
260
                 XMPPEvents.MUC_JOINED,
252
                 () => {
261
                 () => {
253
-                    this._leaveLobbyRoom();
262
+                    this.leave();
254
                 });
263
                 });
255
         }
264
         }
256
 
265
 
301
             return;
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
         const memberRoomJid = Object.keys(this.lobbyRoom.members)
321
         const memberRoomJid = Object.keys(this.lobbyRoom.members)
305
             .find(j => Strophe.getResourceFromJid(j) === id);
322
             .find(j => Strophe.getResourceFromJid(j) === id);
306
 
323
 
307
         if (memberRoomJid) {
324
         if (memberRoomJid) {
308
             const jid = this.lobbyRoom.members[memberRoomJid].jid;
325
             const jid = this.lobbyRoom.members[memberRoomJid].jid;
309
             const msgToSend
326
             const msgToSend
310
-                = $msg({ to: this.mainRoom.roomjid })
327
+                = $msg({ to: mainRoomJid })
311
                     .c('x', { xmlns: 'http://jabber.org/protocol/muc#user' })
328
                     .c('x', { xmlns: 'http://jabber.org/protocol/muc#user' })
312
                     .c('invite', { to: jid });
329
                     .c('invite', { to: jid });
313
 
330
 

+ 12
- 3
modules/xmpp/xmpp.js View File

453
             if (identity.type === 'region') {
453
             if (identity.type === 'region') {
454
                 this.options.deploymentInfo.region = this.connection.region = identity.name;
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
         this._maybeSendDeploymentInfoStat(true);
462
         this._maybeSendDeploymentInfoStat(true);
645
      * @returns {Promise} Resolves with an instance of a strophe muc.
649
      * @returns {Promise} Resolves with an instance of a strophe muc.
646
      */
650
      */
647
     createRoom(roomName, options, onCreateResource) {
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
         const mucNickname = onCreateResource
657
         const mucNickname = onCreateResource
652
             ? onCreateResource(this.connection.jid, this.authenticatedUser)
658
             ? onCreateResource(this.connection.jid, this.authenticatedUser)
653
             : RandomUtil.randomHexString(8).toLowerCase();
659
             : RandomUtil.randomHexString(8).toLowerCase();
979
 
985
 
980
         if (!(from === this.speakerStatsComponentAddress
986
         if (!(from === this.speakerStatsComponentAddress
981
             || from === this.conferenceDurationComponentAddress
987
             || from === this.conferenceDurationComponentAddress
982
-            || from === this.avModerationComponentAddress)) {
988
+            || from === this.avModerationComponentAddress
989
+            || from === this.breakoutRoomsComponentAddress)) {
983
             return true;
990
             return true;
984
         }
991
         }
985
 
992
 
997
             this.eventEmitter.emit(XMPPEvents.CONFERENCE_TIMESTAMP_RECEIVED, parsedJson.created_timestamp);
1004
             this.eventEmitter.emit(XMPPEvents.CONFERENCE_TIMESTAMP_RECEIVED, parsedJson.created_timestamp);
998
         } else if (parsedJson[JITSI_MEET_MUC_TYPE] === 'av_moderation') {
1005
         } else if (parsedJson[JITSI_MEET_MUC_TYPE] === 'av_moderation') {
999
             this.eventEmitter.emit(XMPPEvents.AV_MODERATION_RECEIVED, parsedJson);
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
         return true;
1011
         return true;

+ 15
- 0
service/xmpp/XMPPEvents.js View File

281
      */
281
      */
282
     AV_MODERATION_PARTICIPANT_REJECTED: 'xmpp.av_moderation.participant.rejected',
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
     // Designates an event indicating that we should join the conference with
299
     // Designates an event indicating that we should join the conference with
285
     // audio and/or video muted.
300
     // audio and/or video muted.
286
     START_MUTED_FROM_FOCUS: 'xmpp.start_muted_from_focus',
301
     START_MUTED_FROM_FOCUS: 'xmpp.start_muted_from_focus',

Loading…
Cancel
Save