Browse Source

feat(breakout-rooms) add breakout-rooms

- implement breakout-rooms
- integrated into the participants panel
- managed by moderators
- moderators can send participants to breakout-rooms
- participants can join breakout rooms by themselve
- participants can leave breakout rooms anytime

Co-authored-by: Robert Pintilii <robert.pin9@gmail.com>
Co-authored-by: Saúl Ibarra Corretgé <saghul@jitsi.org>
master
Werner Fleischer 3 years ago
parent
commit
b5faf9f62a
60 changed files with 2483 additions and 132 deletions
  1. 57
    3
      conference.js
  2. 4
    1
      config.js
  3. 14
    0
      doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example
  4. 18
    0
      lang/main.json
  5. 1
    0
      react/features/app/middlewares.any.js
  6. 1
    0
      react/features/app/reducers.any.js
  7. 79
    0
      react/features/base/components/context-menu/useContextMenu.js
  8. 4
    0
      react/features/base/components/index.js
  9. 80
    8
      react/features/base/components/participants-pane-list/ListItem.js
  10. 11
    0
      react/features/base/conference/actionTypes.js
  11. 41
    2
      react/features/base/conference/actions.js
  12. 1
    0
      react/features/base/config/configWhitelist.js
  13. 3
    0
      react/features/base/icons/svg/icon-ring-group.svg
  14. 1
    0
      react/features/base/icons/svg/index.js
  15. 7
    0
      react/features/breakout-rooms/actionTypes.js
  16. 236
    0
      react/features/breakout-rooms/actions.js
  17. 3
    0
      react/features/breakout-rooms/components/_.native.js
  18. 3
    0
      react/features/breakout-rooms/components/_.web.js
  19. 3
    0
      react/features/breakout-rooms/components/index.js
  20. 31
    0
      react/features/breakout-rooms/components/native/AddBreakoutRoomButton.js
  21. 31
    0
      react/features/breakout-rooms/components/native/AutoAssignButton.js
  22. 85
    0
      react/features/breakout-rooms/components/native/BreakoutRoomContextMenu.js
  23. 25
    0
      react/features/breakout-rooms/components/native/BreakoutRoomParticipantItem.js
  24. 72
    0
      react/features/breakout-rooms/components/native/CollapsibleRoom.js
  25. 31
    0
      react/features/breakout-rooms/components/native/LeaveBreakoutRoomButton.js
  26. 5
    0
      react/features/breakout-rooms/components/native/index.js
  27. 69
    0
      react/features/breakout-rooms/components/native/styles.js
  28. 36
    0
      react/features/breakout-rooms/components/web/AddBreakoutRoomButton.js
  29. 42
    0
      react/features/breakout-rooms/components/web/AutoAssignButton.js
  30. 127
    0
      react/features/breakout-rooms/components/web/CollapsibleRoom.js
  31. 47
    0
      react/features/breakout-rooms/components/web/JoinQuickActionButton.js
  32. 42
    0
      react/features/breakout-rooms/components/web/LeaveButton.js
  33. 40
    0
      react/features/breakout-rooms/components/web/RoomActionEllipsis.js
  34. 100
    0
      react/features/breakout-rooms/components/web/RoomContextMenu.js
  35. 63
    0
      react/features/breakout-rooms/components/web/RoomList.js
  36. 4
    0
      react/features/breakout-rooms/components/web/index.js
  37. 22
    0
      react/features/breakout-rooms/constants.js
  38. 59
    0
      react/features/breakout-rooms/functions.js
  39. 7
    0
      react/features/breakout-rooms/logger.js
  40. 31
    0
      react/features/breakout-rooms/middleware.js
  41. 25
    0
      react/features/breakout-rooms/reducer.js
  42. 16
    2
      react/features/conference/components/native/LonelyMeetingExperience.js
  43. 15
    2
      react/features/participants-pane/components/native/MeetingParticipantList.js
  44. 5
    4
      react/features/participants-pane/components/native/ParticipantItem.js
  45. 33
    0
      react/features/participants-pane/components/native/ParticipantsPane.js
  46. 1
    2
      react/features/participants-pane/components/web/FooterContextMenu.js
  47. 1
    1
      react/features/participants-pane/components/web/LobbyParticipantQuickAction.js
  48. 79
    5
      react/features/participants-pane/components/web/MeetingParticipantContextMenu.js
  49. 1
    2
      react/features/participants-pane/components/web/MeetingParticipantItem.js
  50. 30
    77
      react/features/participants-pane/components/web/MeetingParticipants.js
  51. 1
    1
      react/features/participants-pane/components/web/ParticipantActionEllipsis.js
  52. 11
    10
      react/features/participants-pane/components/web/ParticipantItem.js
  53. 1
    1
      react/features/participants-pane/components/web/ParticipantQuickAction.js
  54. 25
    4
      react/features/participants-pane/components/web/ParticipantsPane.js
  55. 3
    1
      react/features/participants-pane/functions.js
  56. 41
    5
      react/features/video-menu/components/native/RemoteVideoMenu.js
  57. 77
    0
      react/features/video-menu/components/native/SendToBreakoutRoom.js
  58. 14
    0
      react/features/video-menu/components/native/styles.js
  59. 555
    0
      resources/prosody-plugins/mod_muc_breakout_rooms.lua
  60. 13
    1
      resources/prosody-plugins/mod_muc_lobby_rooms.lua

+ 57
- 3
conference.js View File

29
 import {
29
 import {
30
     AVATAR_URL_COMMAND,
30
     AVATAR_URL_COMMAND,
31
     EMAIL_COMMAND,
31
     EMAIL_COMMAND,
32
+    _conferenceWillJoin,
32
     authStatusChanged,
33
     authStatusChanged,
33
     commonUserJoinedHandling,
34
     commonUserJoinedHandling,
34
     commonUserLeftHandling,
35
     commonUserLeftHandling,
47
     onStartMutedPolicyChanged,
48
     onStartMutedPolicyChanged,
48
     p2pStatusChanged,
49
     p2pStatusChanged,
49
     sendLocalParticipant,
50
     sendLocalParticipant,
50
-    _conferenceWillJoin
51
+    nonParticipantMessageReceived
51
 } from './react/features/base/conference';
52
 } from './react/features/base/conference';
52
 import { getReplaceParticipant } from './react/features/base/config/functions';
53
 import { getReplaceParticipant } from './react/features/base/config/functions';
53
 import {
54
 import {
1360
         }
1361
         }
1361
     },
1362
     },
1362
 
1363
 
1363
-    _createRoom(localTracks) {
1364
+    /**
1365
+     * Used by the Breakout Rooms feature to join a breakout room or go back to the main room.
1366
+     */
1367
+    async joinRoom(roomName, isBreakoutRoom = false) {
1368
+        this.roomName = roomName;
1369
+
1370
+        const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks();
1371
+        const localTracks = await tryCreateLocalTracks;
1372
+
1373
+        this._displayErrorsForCreateInitialLocalTracks(errors);
1374
+        localTracks.forEach(track => {
1375
+            if ((track.isAudioTrack() && this.isLocalAudioMuted())
1376
+                || (track.isVideoTrack() && this.isLocalVideoMuted())) {
1377
+                track.mute();
1378
+            }
1379
+        });
1380
+        this._createRoom(localTracks, isBreakoutRoom);
1381
+
1382
+        return new Promise((resolve, reject) => {
1383
+            new ConferenceConnector(resolve, reject).connect();
1384
+        });
1385
+    },
1386
+
1387
+    _createRoom(localTracks, isBreakoutRoom = false) {
1388
+        const extraOptions = {};
1389
+
1390
+        if (isBreakoutRoom) {
1391
+            // We must be in a room already.
1392
+            if (!room?.xmpp?.breakoutRoomsComponentAddress) {
1393
+                throw new Error('Breakout Rooms not enabled');
1394
+            }
1395
+
1396
+            // TODO: re-evaluate this. -saghul
1397
+            extraOptions.customDomain = room.xmpp.breakoutRoomsComponentAddress;
1398
+        }
1399
+
1364
         room
1400
         room
1365
             = connection.initJitsiConference(
1401
             = connection.initJitsiConference(
1366
                 APP.conference.roomName,
1402
                 APP.conference.roomName,
1367
-                this._getConferenceOptions());
1403
+                {
1404
+                    ...this._getConferenceOptions(),
1405
+                    ...extraOptions
1406
+                });
1368
 
1407
 
1369
         // Filter out the tracks that are muted (except on Safari).
1408
         // Filter out the tracks that are muted (except on Safari).
1370
         const tracks = browser.isWebKitBased() ? localTracks : localTracks.filter(track => !track.isMuted());
1409
         const tracks = browser.isWebKitBased() ? localTracks : localTracks.filter(track => !track.isMuted());
2222
                 }
2261
                 }
2223
             });
2262
             });
2224
 
2263
 
2264
+        room.on(
2265
+            JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
2266
+            (...args) => APP.store.dispatch(nonParticipantMessageReceived(...args)));
2267
+
2225
         room.on(
2268
         room.on(
2226
             JitsiConferenceEvents.LOCK_STATE_CHANGED,
2269
             JitsiConferenceEvents.LOCK_STATE_CHANGED,
2227
             (...args) => APP.store.dispatch(lockStateChanged(room, ...args)));
2270
             (...args) => APP.store.dispatch(lockStateChanged(room, ...args)));
2904
         });
2947
         });
2905
     },
2948
     },
2906
 
2949
 
2950
+    /**
2951
+     * Leaves the room.
2952
+     *
2953
+     * @returns {Promise}
2954
+     */
2955
+    leaveRoom() {
2956
+        if (room && room.isJoined()) {
2957
+            return room.leave();
2958
+        }
2959
+    },
2960
+
2907
     /**
2961
     /**
2908
      * Leaves the room and calls JitsiConnection.disconnect.
2962
      * Leaves the room and calls JitsiConnection.disconnect.
2909
      *
2963
      *

+ 4
- 1
config.js View File

431
     // hideLobbyButton: false,
431
     // hideLobbyButton: false,
432
 
432
 
433
     // If Lobby is enabled starts knocking automatically.
433
     // If Lobby is enabled starts knocking automatically.
434
-    // autoKnockLobby: false
434
+    // autoKnockLobby: false,
435
+
436
+    // Hides add breakout room button
437
+    // hideAddRoomButton: false,
435
 
438
 
436
     // Require users to always specify a display name.
439
     // Require users to always specify a display name.
437
     // requireDisplayName: true,
440
     // requireDisplayName: true,

+ 14
- 0
doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example View File

52
         "external_services";
52
         "external_services";
53
         "conference_duration";
53
         "conference_duration";
54
         "muc_lobby_rooms";
54
         "muc_lobby_rooms";
55
+        "muc_breakout_rooms";
55
         "av_moderation";
56
         "av_moderation";
56
     }
57
     }
57
     c2s_require_encryption = false
58
     c2s_require_encryption = false
58
     lobby_muc = "lobby.jitmeet.example.com"
59
     lobby_muc = "lobby.jitmeet.example.com"
60
+    breakout_rooms_muc = "breakout.jitmeet.example.com"
59
     main_muc = "conference.jitmeet.example.com"
61
     main_muc = "conference.jitmeet.example.com"
60
     -- muc_lobby_whitelist = { "recorder.jitmeet.example.com" } -- Here we can whitelist jibri to enter lobby enabled rooms
62
     -- muc_lobby_whitelist = { "recorder.jitmeet.example.com" } -- Here we can whitelist jibri to enter lobby enabled rooms
61
 
63
 
73
     muc_room_locking = false
75
     muc_room_locking = false
74
     muc_room_default_public_jids = true
76
     muc_room_default_public_jids = true
75
 
77
 
78
+Component "breakout.jitmeet.example.com" "muc"
79
+    restrict_room_creation = true
80
+    storage = "memory"
81
+    modules_enabled = {
82
+        "muc_meeting_id";
83
+        "muc_domain_mapper";
84
+        --"token_verification";
85
+    }
86
+    admins = { "focusUser@auth.jitmeet.example.com" }
87
+    muc_room_locking = false
88
+    muc_room_default_public_jids = true
89
+
76
 -- internal muc component
90
 -- internal muc component
77
 Component "internal.auth.jitmeet.example.com" "muc"
91
 Component "internal.auth.jitmeet.example.com" "muc"
78
     storage = "memory"
92
     storage = "memory"

+ 18
- 0
lang/main.json View File

39
     "audioOnly": {
39
     "audioOnly": {
40
         "audioOnly": "Low bandwidth"
40
         "audioOnly": "Low bandwidth"
41
     },
41
     },
42
+    "breakoutRooms": {
43
+        "defaultName": "Breakout room #{{index}}",
44
+        "mainRoom": "Main room",
45
+        "actions": {
46
+            "add": "Add breakout room",
47
+            "autoAssign": "Auto assign to breakout rooms",
48
+            "close": "Close",
49
+            "join": "Join",
50
+            "leaveBreakoutRoom": "Leave breakout room",
51
+            "more": "More",
52
+            "remove": "Remove",
53
+            "sendToBreakoutRoom": "Send participant to:"
54
+        }
55
+    },
42
     "calendarSync": {
56
     "calendarSync": {
43
         "addMeetingURL": "Add a meeting link",
57
         "addMeetingURL": "Add a meeting link",
44
         "confirmAddLink": "Do you want to add a Jitsi link to this event?",
58
         "confirmAddLink": "Do you want to add a Jitsi link to this event?",
623
             "invite": "Invite Someone",
637
             "invite": "Invite Someone",
624
             "askUnmute": "Ask to unmute",
638
             "askUnmute": "Ask to unmute",
625
             "moreModerationActions": "More moderation options",
639
             "moreModerationActions": "More moderation options",
640
+            "moreParticipantOptions": "More participant options",
626
             "mute": "Mute",
641
             "mute": "Mute",
627
             "muteAll": "Mute all",
642
             "muteAll": "Mute all",
628
             "muteEveryoneElse": "Mute everyone else",
643
             "muteEveryoneElse": "Mute everyone else",
886
             "audioOnly": "Toggle audio only",
901
             "audioOnly": "Toggle audio only",
887
             "audioRoute": "Select the sound device",
902
             "audioRoute": "Select the sound device",
888
             "boo": "Boo",
903
             "boo": "Boo",
904
+            "breakoutRoom": "Join/leave breakout room",
889
             "callQuality": "Manage video quality",
905
             "callQuality": "Manage video quality",
890
             "cc": "Toggle subtitles",
906
             "cc": "Toggle subtitles",
891
             "chat": "Open / Close chat",
907
             "chat": "Open / Close chat",
969
         "hangup": "Leave the meeting",
985
         "hangup": "Leave the meeting",
970
         "help": "Help",
986
         "help": "Help",
971
         "invite": "Invite people",
987
         "invite": "Invite people",
988
+        "joinBreakoutRoom": "Join breakout room",
972
         "laugh": "Laugh",
989
         "laugh": "Laugh",
990
+        "leaveBreakoutRoom": "Leave breakout room",
973
         "like": "Thumbs Up",
991
         "like": "Thumbs Up",
974
         "lobbyButtonDisable": "Disable lobby mode",
992
         "lobbyButtonDisable": "Disable lobby mode",
975
         "lobbyButtonEnable": "Enable lobby mode",
993
         "lobbyButtonEnable": "Enable lobby mode",

+ 1
- 0
react/features/app/middlewares.any.js View File

19
 import '../base/testing/middleware';
19
 import '../base/testing/middleware';
20
 import '../base/tracks/middleware';
20
 import '../base/tracks/middleware';
21
 import '../base/user-interaction/middleware';
21
 import '../base/user-interaction/middleware';
22
+import '../breakout-rooms/middleware';
22
 import '../calendar-sync/middleware';
23
 import '../calendar-sync/middleware';
23
 import '../chat/middleware';
24
 import '../chat/middleware';
24
 import '../conference/middleware';
25
 import '../conference/middleware';

+ 1
- 0
react/features/app/reducers.any.js View File

26
 import '../base/testing/reducer';
26
 import '../base/testing/reducer';
27
 import '../base/tracks/reducer';
27
 import '../base/tracks/reducer';
28
 import '../base/user-interaction/reducer';
28
 import '../base/user-interaction/reducer';
29
+import '../breakout-rooms/reducer';
29
 import '../calendar-sync/reducer';
30
 import '../calendar-sync/reducer';
30
 import '../chat/reducer';
31
 import '../chat/reducer';
31
 import '../deep-linking/reducer';
32
 import '../deep-linking/reducer';

+ 79
- 0
react/features/base/components/context-menu/useContextMenu.js View File

1
+// @flow
2
+
3
+import { useCallback, useRef, useState } from 'react';
4
+
5
+import { findAncestorByClass } from '../../../participants-pane/functions';
6
+
7
+type NullProto = {
8
+    [key: string]: any,
9
+    __proto__: null
10
+};
11
+
12
+type RaiseContext = NullProto | {|
13
+
14
+    /**
15
+     * Target elements against which positioning calculations are made.
16
+     */
17
+    offsetTarget?: HTMLElement,
18
+
19
+    /**
20
+     * The entity for which the menu is context menu is raised.
21
+     */
22
+    entity?: string | Object,
23
+|};
24
+
25
+const initialState = Object.freeze(Object.create(null));
26
+
27
+const useContextMenu = () => {
28
+    const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
29
+    const isMouseOverMenu = useRef(false);
30
+
31
+    const lowerMenu = useCallback((force: boolean | Object = false) => {
32
+        /**
33
+         * We are tracking mouse movement over the active participant item and
34
+         * the context menu. Due to the order of enter/leave events, we need to
35
+         * defer checking if the mouse is over the context menu with
36
+         * queueMicrotask.
37
+         */
38
+        window.queueMicrotask(() => {
39
+            if (isMouseOverMenu.current && !(force === true)) {
40
+                return;
41
+            }
42
+
43
+            if (raiseContext !== initialState) {
44
+                setRaiseContext(initialState);
45
+            }
46
+        });
47
+    }, [ raiseContext ]);
48
+
49
+    const raiseMenu = useCallback((entity: string | Object, target: EventTarget) => {
50
+        setRaiseContext({
51
+            entity,
52
+            offsetTarget: findAncestorByClass(target, 'list-item-container')
53
+        });
54
+    }, [ raiseContext ]);
55
+
56
+    const toggleMenu = useCallback((entity: string | Object) => (e: MouseEvent) => {
57
+        e.stopPropagation();
58
+        const { entity: raisedEntity } = raiseContext;
59
+
60
+        if (raisedEntity && raisedEntity === entity) {
61
+            lowerMenu();
62
+        } else {
63
+            raiseMenu(entity, e.target);
64
+        }
65
+    }, [ raiseContext ]);
66
+
67
+    const menuEnter = useCallback(() => {
68
+        isMouseOverMenu.current = true;
69
+    }, []);
70
+
71
+    const menuLeave = useCallback(() => {
72
+        isMouseOverMenu.current = false;
73
+        lowerMenu();
74
+    }, [ lowerMenu ]);
75
+
76
+    return [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ];
77
+};
78
+
79
+export default useContextMenu;

+ 4
- 0
react/features/base/components/index.js View File

1
+export { default as ContextMenu } from './context-menu/ContextMenu';
2
+export { default as ContextMenuItemGroup } from './context-menu/ContextMenuItemGroup';
3
+export { default as ListItem } from './participants-pane-list/ListItem';
4
+export { default as QuickActionButton } from './buttons/QuickActionButton';

react/features/base/components/particpants-pane-list/ListItem.js → react/features/base/components/participants-pane-list/ListItem.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
 import { makeStyles } from '@material-ui/styles';
3
 import { makeStyles } from '@material-ui/styles';
4
+import clsx from 'clsx';
4
 import React from 'react';
5
 import React from 'react';
5
 
6
 
6
 import { ACTION_TRIGGER } from '../../../participants-pane/constants';
7
 import { ACTION_TRIGGER } from '../../../participants-pane/constants';
8
+import { isMobileBrowser } from '../../environment/utils';
7
 import participantsPaneTheme from '../themes/participantsPaneTheme.json';
9
 import participantsPaneTheme from '../themes/participantsPaneTheme.json';
8
 
10
 
9
 type Props = {
11
 type Props = {
13
      */
15
      */
14
     actions: React$Node,
16
     actions: React$Node,
15
 
17
 
18
+    /**
19
+     * List item container class name.
20
+     */
21
+    className: string,
22
+
16
     /**
23
     /**
17
      * Icon to be displayed on the list item. (Avatar for participants).
24
      * Icon to be displayed on the list item. (Avatar for participants).
18
      */
25
      */
43
      */
50
      */
44
     onClick: Function,
51
     onClick: Function,
45
 
52
 
53
+    /**
54
+     * Long press handler.
55
+     */
56
+    onLongPress: Function,
57
+
46
     /**
58
     /**
47
      * Mouse leave handler.
59
      * Mouse leave handler.
48
      */
60
      */
49
     onMouseLeave: Function,
61
     onMouseLeave: Function,
50
 
62
 
63
+    /**
64
+     * Data test id.
65
+     */
66
+    testId?: string,
67
+
51
     /**
68
     /**
52
      * Text children to be displayed on the list item.
69
      * Text children to be displayed on the list item.
53
      */
70
      */
72
             padding: `0 ${participantsPaneTheme.panePadding}px`,
89
             padding: `0 ${participantsPaneTheme.panePadding}px`,
73
             position: 'relative',
90
             position: 'relative',
74
             boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
91
             boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
92
+            minHeight: '40px',
75
 
93
 
76
             '&:hover': {
94
             '&:hover': {
77
                 backgroundColor: theme.palette.action02Active,
95
                 backgroundColor: theme.palette.action02Active,
161
 
179
 
162
 const ListItem = ({
180
 const ListItem = ({
163
     actions,
181
     actions,
182
+    className,
164
     icon,
183
     icon,
165
     id,
184
     id,
166
     hideActions = false,
185
     hideActions = false,
167
     indicators,
186
     indicators,
168
     isHighlighted,
187
     isHighlighted,
169
     onClick,
188
     onClick,
189
+    onLongPress,
170
     onMouseLeave,
190
     onMouseLeave,
191
+    testId,
171
     textChildren,
192
     textChildren,
172
     trigger
193
     trigger
173
 }: Props) => {
194
 }: Props) => {
174
     const styles = useStyles();
195
     const styles = useStyles();
196
+    const _isMobile = isMobileBrowser();
197
+    let timeoutHandler;
198
+
199
+    /**
200
+     * Set calling long press handler after x milliseconds.
201
+     *
202
+     * @param {TouchEvent} e - Touch start event.
203
+     * @returns {void}
204
+     */
205
+    function _onTouchStart(e) {
206
+        const target = e.touches[0].target;
207
+
208
+        timeoutHandler = setTimeout(() => onLongPress(target), 600);
209
+    }
210
+
211
+    /**
212
+     * Cancel calling on long press after x milliseconds if the number of milliseconds is not reached
213
+     * before a touch move(drag), or just clears the timeout.
214
+     *
215
+     * @returns {void}
216
+     */
217
+    function _onTouchMove() {
218
+        clearTimeout(timeoutHandler);
219
+    }
220
+
221
+    /**
222
+     * Cancel calling on long press after x milliseconds if the number of milliseconds is not reached yet,
223
+     * or just clears the timeout.
224
+     *
225
+     * @returns {void}
226
+     */
227
+    function _onTouchEnd() {
228
+        clearTimeout(timeoutHandler);
229
+    }
175
 
230
 
176
     return (
231
     return (
177
         <div
232
         <div
178
-            className = { `list-item-container ${styles.container} ${isHighlighted ? styles.highlighted : ''}` }
233
+            className = { clsx('list-item-container',
234
+                styles.container,
235
+                isHighlighted && styles.highlighted,
236
+                className
237
+            ) }
238
+            data-testid = { testId }
179
             id = { id }
239
             id = { id }
180
             onClick = { onClick }
240
             onClick = { onClick }
181
-            onMouseLeave = { onMouseLeave }>
241
+            { ...(_isMobile
242
+                ? {
243
+                    onTouchEnd: _onTouchEnd,
244
+                    onTouchMove: _onTouchMove,
245
+                    onTouchStart: _onTouchStart
246
+                }
247
+                : {
248
+                    onMouseLeave
249
+                }
250
+            ) }>
182
             <div> {icon} </div>
251
             <div> {icon} </div>
183
             <div className = { styles.detailsContainer }>
252
             <div className = { styles.detailsContainer }>
184
                 <div className = { styles.name }>
253
                 <div className = { styles.name }>
186
                 </div>
255
                 </div>
187
                 {indicators && (
256
                 {indicators && (
188
                     <div
257
                     <div
189
-                        className = { `indicators ${styles.indicators} ${
190
-                            isHighlighted || trigger === ACTION_TRIGGER.PERMANENT
191
-                                ? styles.indicatorsHidden : ''}` }>
258
+                        className = { clsx('indicators',
259
+                            styles.indicators,
260
+                            (isHighlighted || trigger === ACTION_TRIGGER.PERMANENT) && styles.indicatorsHidden
261
+                        ) }>
192
                         {indicators}
262
                         {indicators}
193
                     </div>
263
                     </div>
194
                 )}
264
                 )}
195
                 {!hideActions && (
265
                 {!hideActions && (
196
                     <div
266
                     <div
197
-                        className = { `actions ${styles.actionsContainer} ${
198
-                            trigger === ACTION_TRIGGER.PERMANENT ? styles.actionsPermanent : ''} ${
199
-                            isHighlighted ? styles.actionsVisible : ''}` }>
267
+                        className = { clsx('actions',
268
+                            styles.actionsContainer,
269
+                            trigger === ACTION_TRIGGER.PERMANENT && styles.actionsPermanent,
270
+                            isHighlighted && styles.actionsVisible
271
+                        ) }>
200
                         {actions}
272
                         {actions}
201
                     </div>
273
                     </div>
202
                 )}
274
                 )}

+ 11
- 0
react/features/base/conference/actionTypes.js View File

127
  */
127
  */
128
 export const LOCK_STATE_CHANGED = 'LOCK_STATE_CHANGED';
128
 export const LOCK_STATE_CHANGED = 'LOCK_STATE_CHANGED';
129
 
129
 
130
+/**
131
+ * The type of (redux) action which signals that a system (non-participant) message has been received.
132
+ *
133
+ * {
134
+ *     type: NON_PARTICIPANT_MESSAGE_RECEIVED,
135
+ *     id: String,
136
+ *     json: Object
137
+ * }
138
+ */
139
+export const NON_PARTICIPANT_MESSAGE_RECEIVED = 'NON_PARTICIPANT_MESSAGE_RECEIVED';
140
+
130
 /**
141
 /**
131
  * The type of (redux) action which sets the peer2peer flag for the current
142
  * The type of (redux) action which sets the peer2peer flag for the current
132
  * conference.
143
  * conference.

+ 41
- 2
react/features/base/conference/actions.js View File

37
     DATA_CHANNEL_OPENED,
37
     DATA_CHANNEL_OPENED,
38
     KICKED_OUT,
38
     KICKED_OUT,
39
     LOCK_STATE_CHANGED,
39
     LOCK_STATE_CHANGED,
40
+    NON_PARTICIPANT_MESSAGE_RECEIVED,
40
     P2P_STATUS_CHANGED,
41
     P2P_STATUS_CHANGED,
41
     SEND_TONES,
42
     SEND_TONES,
42
     SET_FOLLOW_ME,
43
     SET_FOLLOW_ME,
179
         JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
180
         JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
180
         (...args) => dispatch(endpointMessageReceived(...args)));
181
         (...args) => dispatch(endpointMessageReceived(...args)));
181
 
182
 
183
+    conference.on(
184
+        JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
185
+        (...args) => dispatch(nonParticipantMessageReceived(...args)));
186
+
182
     conference.on(
187
     conference.on(
183
         JitsiConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED,
188
         JitsiConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED,
184
         (...args) => dispatch(participantConnectionStatusChanged(...args)));
189
         (...args) => dispatch(participantConnectionStatusChanged(...args)));
415
 /**
420
 /**
416
  * Initializes a new conference.
421
  * Initializes a new conference.
417
  *
422
  *
423
+ * @param {string} overrideRoom - Override the room to join, instead of taking it
424
+ * from Redux.
418
  * @returns {Function}
425
  * @returns {Function}
419
  */
426
  */
420
-export function createConference() {
427
+export function createConference(overrideRoom?: string) {
421
     return (dispatch: Function, getState: Function) => {
428
     return (dispatch: Function, getState: Function) => {
422
         const state = getState();
429
         const state = getState();
423
         const { connection, locationURL } = state['features/base/connection'];
430
         const { connection, locationURL } = state['features/base/connection'];
432
             throw new Error('Cannot join a conference without a room name!');
439
             throw new Error('Cannot join a conference without a room name!');
433
         }
440
         }
434
 
441
 
435
-        const conference = connection.initJitsiConference(getBackendSafeRoomName(room), getConferenceOptions(state));
442
+        // XXX: revisit this.
443
+        // Hide the custom domain in the room name.
444
+        const tmp = overrideRoom || room;
445
+        let _room = getBackendSafeRoomName(tmp);
446
+
447
+        if (tmp.domain) {
448
+            // eslint-disable-next-line no-new-wrappers
449
+            _room = new String(tmp);
450
+
451
+            // $FlowExpectedError
452
+            _room.domain = tmp.domain;
453
+        }
454
+
455
+        const conference = connection.initJitsiConference(_room, getConferenceOptions(state));
436
 
456
 
437
         connection[JITSI_CONNECTION_CONFERENCE_KEY] = conference;
457
         connection[JITSI_CONNECTION_CONFERENCE_KEY] = conference;
438
 
458
 
525
     };
545
     };
526
 }
546
 }
527
 
547
 
548
+/**
549
+ * Signals that a non participant endpoint message has been received.
550
+ *
551
+ * @param {string} id - The resource id of the sender.
552
+ * @param {Object} json - The json carried by the endpoint message.
553
+ * @returns {{
554
+ *      type: NON_PARTICIPANT_MESSAGE_RECEIVED,
555
+ *      id: Object,
556
+ *      json: Object
557
+ * }}
558
+ */
559
+export function nonParticipantMessageReceived(id: String, json: Object) {
560
+    return {
561
+        type: NON_PARTICIPANT_MESSAGE_RECEIVED,
562
+        id,
563
+        json
564
+    };
565
+}
566
+
528
 /**
567
 /**
529
  * Updates the known state of start muted policies.
568
  * Updates the known state of start muted policies.
530
  *
569
  *

+ 1
- 0
react/features/base/config/configWhitelist.js View File

158
     'hideParticipantsStats',
158
     'hideParticipantsStats',
159
     'hideConferenceTimer',
159
     'hideConferenceTimer',
160
     'hiddenDomain',
160
     'hiddenDomain',
161
+    'hideAddRoomButton',
161
     'hideLobbyButton',
162
     'hideLobbyButton',
162
     'hosts',
163
     'hosts',
163
     'iAmRecorder',
164
     'iAmRecorder',

+ 3
- 0
react/features/base/icons/svg/icon-ring-group.svg View File

1
+<svg width="16" height="15" viewBox="0 0 16 15" xmlns="http://www.w3.org/2000/svg">
2
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8 7.16667C6.85438 7.16667 5.84374 6.58873 5.24374 5.70851C4.57694 6.29711 4.09999 7.09581 3.91648 8.00102C5.71901 8.04514 7.16667 9.52018 7.16667 11.3333C7.16667 11.871 7.03938 12.3789 6.8133 12.8286C7.1894 12.9401 7.5877 13 8 13C8.4123 13 8.81061 12.9401 9.1867 12.8286C8.96062 12.3789 8.83333 11.871 8.83333 11.3333C8.83333 9.52018 10.281 8.04515 12.0835 8.00102C11.9 7.09581 11.4231 6.29711 10.7563 5.70851C10.1563 6.58873 9.14562 7.16667 8 7.16667ZM8 14.6667C8.85231 14.6667 9.66193 14.4839 10.3918 14.1554C10.9057 14.4793 11.5143 14.6667 12.1667 14.6667C14.0076 14.6667 15.5 13.1743 15.5 11.3333C15.5 10.0941 14.8238 9.01283 13.8202 8.43837C13.6984 6.61689 12.7405 5.02432 11.327 4.04114C11.3312 3.97241 11.3333 3.90312 11.3333 3.83333C11.3333 1.99238 9.84095 0.5 8 0.5C6.15905 0.5 4.66667 1.99238 4.66667 3.83333C4.66667 3.90312 4.66881 3.97241 4.67304 4.04114C3.2595 5.02432 2.30161 6.61689 2.17983 8.43837C1.17624 9.01282 0.5 10.0941 0.5 11.3333C0.5 13.1743 1.99238 14.6667 3.83333 14.6667C4.4857 14.6667 5.09428 14.4793 5.60821 14.1554C6.33807 14.4839 7.14769 14.6667 8 14.6667ZM9.66667 3.83333C9.66667 4.75381 8.92047 5.5 8 5.5C7.07952 5.5 6.33333 4.75381 6.33333 3.83333C6.33333 2.91286 7.07952 2.16667 8 2.16667C8.92047 2.16667 9.66667 2.91286 9.66667 3.83333ZM5.5 11.3333C5.5 12.2538 4.75381 13 3.83333 13C2.91286 13 2.16667 12.2538 2.16667 11.3333C2.16667 10.4129 2.91286 9.66667 3.83333 9.66667C4.75381 9.66667 5.5 10.4129 5.5 11.3333ZM13.8333 11.3333C13.8333 12.2538 13.0871 13 12.1667 13C11.2462 13 10.5 12.2538 10.5 11.3333C10.5 10.4129 11.2462 9.66667 12.1667 9.66667C13.0871 9.66667 13.8333 10.4129 13.8333 11.3333Z" fill="white"/>
3
+</svg>

+ 1
- 0
react/features/base/icons/svg/index.js View File

101
 export { default as IconRemoteControlStop } from './stop.svg';
101
 export { default as IconRemoteControlStop } from './stop.svg';
102
 export { default as IconReply } from './reply.svg';
102
 export { default as IconReply } from './reply.svg';
103
 export { default as IconRestore } from './restore.svg';
103
 export { default as IconRestore } from './restore.svg';
104
+export { default as IconRingGroup } from './icon-ring-group.svg';
104
 export { default as IconRoomLock } from './security.svg';
105
 export { default as IconRoomLock } from './security.svg';
105
 export { default as IconRoomUnlock } from './security-locked.svg';
106
 export { default as IconRoomUnlock } from './security-locked.svg';
106
 export { default as IconSecurityOff } from './security-off.svg';
107
 export { default as IconSecurityOff } from './security-off.svg';

+ 7
- 0
react/features/breakout-rooms/actionTypes.js View File

1
+// @flow
2
+
3
+/**
4
+  * The type of (redux) action to update the breakout room data.
5
+  *
6
+  */
7
+export const UPDATE_BREAKOUT_ROOMS = 'UPDATE_BREAKOUT_ROOMS';

+ 236
- 0
react/features/breakout-rooms/actions.js View File

1
+// @flow
2
+
3
+import i18next from 'i18next';
4
+import _ from 'lodash';
5
+import type { Dispatch } from 'redux';
6
+
7
+import {
8
+    conferenceLeft,
9
+    conferenceWillLeave,
10
+    createConference,
11
+    getCurrentConference
12
+} from '../base/conference';
13
+import { setAudioMuted, setVideoMuted } from '../base/media';
14
+import { getRemoteParticipants } from '../base/participants';
15
+import { clearNotifications } from '../notifications';
16
+
17
+import {
18
+    getBreakoutRooms,
19
+    getMainRoom
20
+} from './functions';
21
+import logger from './logger';
22
+
23
+declare var APP: Object;
24
+
25
+/**
26
+ * Action to create a breakout room.
27
+ *
28
+ * @param {string} name - Name / subject for the breakout room.
29
+ * @returns {Function}
30
+ */
31
+export function createBreakoutRoom(name?: string) {
32
+    return (dispatch: Dispatch<any>, getState: Function) => {
33
+        const rooms = getBreakoutRooms(getState);
34
+
35
+        // TODO: remove this once we add UI to customize the name.
36
+        const index = Object.keys(rooms).length;
37
+        const subject = name || i18next.t('breakoutRooms.defaultName', { index });
38
+
39
+        // $FlowExpectedError
40
+        getCurrentConference(getState)?.getBreakoutRooms()
41
+            ?.createBreakoutRoom(subject);
42
+    };
43
+}
44
+
45
+/**
46
+ * Action to close a room and send participants to the main room.
47
+ *
48
+ * @param {string} roomId - The id of the room to close.
49
+ * @returns {Function}
50
+ */
51
+export function closeBreakoutRoom(roomId: string) {
52
+    return (dispatch: Dispatch<any>, getState: Function) => {
53
+        const rooms = getBreakoutRooms(getState);
54
+        const room = rooms[roomId];
55
+        const mainRoom = getMainRoom(getState);
56
+
57
+        if (room && mainRoom) {
58
+            Object.values(room.participants).forEach(p => {
59
+
60
+                // $FlowExpectedError
61
+                dispatch(sendParticipantToRoom(p.jid, mainRoom.id));
62
+            });
63
+        }
64
+    };
65
+}
66
+
67
+/**
68
+ * Action to remove a breakout room.
69
+ *
70
+ * @param {string} breakoutRoomJid - The jid of the breakout room to remove.
71
+ * @returns {Function}
72
+ */
73
+export function removeBreakoutRoom(breakoutRoomJid: string) {
74
+    return (dispatch: Dispatch<any>, getState: Function) => {
75
+        // $FlowExpectedError
76
+        getCurrentConference(getState)?.getBreakoutRooms()
77
+            ?.removeBreakoutRoom(breakoutRoomJid);
78
+    };
79
+}
80
+
81
+/**
82
+ * Action to auto-assign the participants to breakout rooms.
83
+ *
84
+ * @returns {Function}
85
+ */
86
+export function autoAssignToBreakoutRooms() {
87
+    return (dispatch: Dispatch<any>, getState: Function) => {
88
+        const rooms = getBreakoutRooms(getState);
89
+        const breakoutRooms = _.filter(rooms, (room: Object) => !room.isMainRoom);
90
+
91
+        if (breakoutRooms) {
92
+            const participantIds = Array.from(getRemoteParticipants(getState).keys());
93
+            const length = Math.ceil(participantIds.length / breakoutRooms.length);
94
+
95
+            _.chunk(_.shuffle(participantIds), length).forEach((group, index) =>
96
+                group.forEach(participantId => {
97
+                    dispatch(sendParticipantToRoom(participantId, breakoutRooms[index].id));
98
+                })
99
+            );
100
+        }
101
+    };
102
+}
103
+
104
+/**
105
+ * Action to send a participant to a room.
106
+ *
107
+ * @param {string} participantId - The participant id.
108
+ * @param {string} roomId - The room id.
109
+ * @returns {Function}
110
+ */
111
+export function sendParticipantToRoom(participantId: string, roomId: string) {
112
+    return (dispatch: Dispatch<any>, getState: Function) => {
113
+        const rooms = getBreakoutRooms(getState);
114
+        const room = rooms[roomId];
115
+
116
+        if (!room) {
117
+            logger.warn(`Invalid room: ${roomId}`);
118
+
119
+            return;
120
+        }
121
+
122
+        // Get the full JID of the participant. We could be getting the endpoint ID or
123
+        // a participant JID. We want to find the connection JID.
124
+        const participantJid = _findParticipantJid(getState, participantId);
125
+
126
+        if (!participantJid) {
127
+            logger.warn(`Could not find participant ${participantId}`);
128
+
129
+            return;
130
+        }
131
+
132
+        // $FlowExpectedError
133
+        getCurrentConference(getState)?.getBreakoutRooms()
134
+            ?.sendParticipantToRoom(participantJid, room.jid);
135
+    };
136
+}
137
+
138
+/**
139
+ * Action to move to a room.
140
+ *
141
+ * @param {string} roomId - The room id to move to. If omitted move to the main room.
142
+ * @returns {Function}
143
+ */
144
+export function moveToRoom(roomId?: string) {
145
+    return (dispatch: Dispatch<any>, getState: Function) => {
146
+        let _roomId = roomId || getMainRoom(getState)?.id;
147
+
148
+        // Check if we got a full JID.
149
+        // $FlowExpectedError
150
+        if (_roomId?.indexOf('@') !== -1) {
151
+            // $FlowExpectedError
152
+            const [ id, ...domainParts ] = _roomId.split('@');
153
+
154
+            // On mobile we first store the room and the connection is created
155
+            // later, so let's attach the domain to the room String object as
156
+            // a little hack.
157
+
158
+            // eslint-disable-next-line no-new-wrappers
159
+            _roomId = new String(id);
160
+
161
+            // $FlowExpectedError
162
+            _roomId.domain = domainParts.join('@');
163
+        }
164
+
165
+        if (navigator.product === 'ReactNative') {
166
+            const conference = getCurrentConference(getState);
167
+            const { audio, video } = getState()['features/base/media'];
168
+
169
+            dispatch(conferenceWillLeave(conference));
170
+            conference.leave()
171
+            .catch(error => {
172
+                logger.warn(
173
+                    'JitsiConference.leave() rejected with:',
174
+                    error);
175
+
176
+                dispatch(conferenceLeft(conference));
177
+            });
178
+            dispatch(clearNotifications());
179
+
180
+            // dispatch(setRoom(_roomId));
181
+            dispatch(createConference(_roomId));
182
+            dispatch(setAudioMuted(audio.muted));
183
+            dispatch(setVideoMuted(video.muted));
184
+        } else {
185
+            APP.conference.leaveRoom()
186
+                .finally(() => APP.conference.joinRoom(_roomId));
187
+        }
188
+    };
189
+}
190
+
191
+/**
192
+ * Finds a participant's connection JID given its ID.
193
+ *
194
+ * @param {Function} getState - The redux store state getter.
195
+ * @param {string} participantId - ID of the given participant.
196
+ * @returns {string|undefined} - The participant connection JID if found.
197
+ */
198
+function _findParticipantJid(getState: Function, participantId: string) {
199
+    const conference = getCurrentConference(getState);
200
+
201
+    if (!conference) {
202
+        return;
203
+    }
204
+
205
+    // Get the full JID of the participant. We could be getting the endpoint ID or
206
+    // a participant JID. We want to find the connection JID.
207
+    let _participantId = participantId;
208
+    let participantJid;
209
+
210
+    if (!participantId.includes('@')) {
211
+        const p = conference.getParticipantById(participantId);
212
+
213
+        // $FlowExpectedError
214
+        _participantId = p?.getJid(); // This will be the room JID.
215
+    }
216
+
217
+    if (_participantId) {
218
+        const rooms = getBreakoutRooms(getState);
219
+
220
+        for (const room of Object.values(rooms)) {
221
+            // $FlowExpectedError
222
+            const participants = room.participants || {};
223
+            const p = participants[_participantId]
224
+
225
+                // $FlowExpectedError
226
+                || Object.values(participants).find(item => item.jid === _participantId);
227
+
228
+            if (p) {
229
+                participantJid = p.jid;
230
+                break;
231
+            }
232
+        }
233
+    }
234
+
235
+    return participantJid;
236
+}

+ 3
- 0
react/features/breakout-rooms/components/_.native.js View File

1
+// @flow
2
+
3
+export * from './native';

+ 3
- 0
react/features/breakout-rooms/components/_.web.js View File

1
+// @flow
2
+
3
+export * from './web';

+ 3
- 0
react/features/breakout-rooms/components/index.js View File

1
+// @flow
2
+
3
+export * from './_';

+ 31
- 0
react/features/breakout-rooms/components/native/AddBreakoutRoomButton.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { Button } from 'react-native-paper';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import { createBreakoutRoom } from '../../actions';
9
+
10
+import styles from './styles';
11
+
12
+const AddBreakoutRoomButton = () => {
13
+    const { t } = useTranslation();
14
+    const dispatch = useDispatch();
15
+
16
+    const onAdd = useCallback(() =>
17
+        dispatch(createBreakoutRoom())
18
+    , [ dispatch ]);
19
+
20
+    return (
21
+        <Button
22
+            accessibilityLabel = { t('breakoutRooms.actions.add') }
23
+            children = { t('breakoutRooms.actions.add') }
24
+            labelStyle = { styles.addButtonLabel }
25
+            mode = 'contained'
26
+            onPress = { onAdd }
27
+            style = { styles.addButton } />
28
+    );
29
+};
30
+
31
+export default AddBreakoutRoomButton;

+ 31
- 0
react/features/breakout-rooms/components/native/AutoAssignButton.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { Button } from 'react-native-paper';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import { autoAssignToBreakoutRooms } from '../../actions';
9
+
10
+import styles from './styles';
11
+
12
+const AutoAssignButton = () => {
13
+    const { t } = useTranslation();
14
+    const dispatch = useDispatch();
15
+
16
+    const onAutoAssign = useCallback(() => {
17
+        dispatch(autoAssignToBreakoutRooms());
18
+    }, [ dispatch ]);
19
+
20
+    return (
21
+        <Button
22
+            accessibilityLabel = { t('breakoutRooms.actions.autoAssign') }
23
+            children = { t('breakoutRooms.actions.autoAssign') }
24
+            labelStyle = { styles.autoAssignLabel }
25
+            mode = 'contained'
26
+            onPress = { onAutoAssign }
27
+            style = { styles.transparentButton } />
28
+    );
29
+};
30
+
31
+export default AutoAssignButton;

+ 85
- 0
react/features/breakout-rooms/components/native/BreakoutRoomContextMenu.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { TouchableOpacity } from 'react-native';
6
+import { Text } from 'react-native-paper';
7
+import { useDispatch, useSelector } from 'react-redux';
8
+
9
+import { hideDialog } from '../../../base/dialog/actions';
10
+import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
11
+import {
12
+    Icon,
13
+    IconClose,
14
+    IconRingGroup
15
+} from '../../../base/icons';
16
+import { isLocalParticipantModerator } from '../../../base/participants';
17
+import styles from '../../../participants-pane/components/native/styles';
18
+import { closeBreakoutRoom, moveToRoom, removeBreakoutRoom } from '../../actions';
19
+
20
+type Props = {
21
+
22
+    /**
23
+     * The room for which the menu is open.
24
+     */
25
+    room: Object
26
+}
27
+
28
+const BreakoutRoomContextMenu = ({ room }: Props) => {
29
+    const dispatch = useDispatch();
30
+    const closeDialog = useCallback(() => dispatch(hideDialog()), [ dispatch ]);
31
+    const isLocalModerator = useSelector(isLocalParticipantModerator);
32
+    const { t } = useTranslation();
33
+
34
+    const onJoinRoom = useCallback(() => {
35
+        dispatch(moveToRoom(room.jid));
36
+        closeDialog();
37
+    }, [ dispatch, room ]);
38
+
39
+    const onRemoveBreakoutRoom = useCallback(() => {
40
+        dispatch(removeBreakoutRoom(room.jid));
41
+        closeDialog();
42
+    }, [ dispatch, room ]);
43
+
44
+    const onCloseBreakoutRoom = useCallback(() => {
45
+        dispatch(closeBreakoutRoom(room.id));
46
+        closeDialog();
47
+    }, [ dispatch, room ]);
48
+
49
+    return (
50
+        <BottomSheet
51
+            addScrollViewPadding = { false }
52
+            onCancel = { closeDialog }
53
+            showSlidingView = { true }>
54
+            <TouchableOpacity
55
+                onPress = { onJoinRoom }
56
+                style = { styles.contextMenuItem }>
57
+                <Icon
58
+                    size = { 24 }
59
+                    src = { IconRingGroup } />
60
+                <Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.join')}</Text>
61
+            </TouchableOpacity>
62
+            {!room?.isMainRoom && isLocalModerator
63
+                && !(room?.participants && Object.keys(room.participants).length > 0)
64
+                ? <TouchableOpacity
65
+                    onPress = { onRemoveBreakoutRoom }
66
+                    style = { styles.contextMenuItem }>
67
+                    <Icon
68
+                        size = { 24 }
69
+                        src = { IconClose } />
70
+                    <Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.remove')}</Text>
71
+                </TouchableOpacity>
72
+                : <TouchableOpacity
73
+                    onPress = { onCloseBreakoutRoom }
74
+                    style = { styles.contextMenuItem }>
75
+                    <Icon
76
+                        size = { 24 }
77
+                        src = { IconClose } />
78
+                    <Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.close')}</Text>
79
+                </TouchableOpacity>
80
+            }
81
+        </BottomSheet>
82
+    );
83
+};
84
+
85
+export default BreakoutRoomContextMenu;

+ 25
- 0
react/features/breakout-rooms/components/native/BreakoutRoomParticipantItem.js View File

1
+// @flow
2
+
3
+import React from 'react';
4
+
5
+import { isParticipantModerator } from '../../../base/participants';
6
+import ParticipantItem from '../../../participants-pane/components/native/ParticipantItem';
7
+
8
+type Props = {
9
+
10
+    /**
11
+     * Participant to be displayed.
12
+     */
13
+    item: Object
14
+}
15
+
16
+const BreakoutRoomParticipantItem = ({ item }: Props) => (
17
+    <ParticipantItem
18
+        displayName = { item.displayName }
19
+        isKnockingParticipant = { false }
20
+        isModerator = { isParticipantModerator(item) }
21
+        key = { item.jid }
22
+        participantID = { item.jid } />
23
+);
24
+
25
+export default BreakoutRoomParticipantItem;

+ 72
- 0
react/features/breakout-rooms/components/native/CollapsibleRoom.js View File

1
+// @flow
2
+
3
+import React, { useCallback, useState } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { FlatList, Text, TouchableOpacity, View } from 'react-native';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import { openDialog } from '../../../base/dialog';
9
+import { Icon, IconArrowDown, IconArrowUp } from '../../../base/icons';
10
+
11
+import BreakoutRoomContextMenu from './BreakoutRoomContextMenu';
12
+import BreakoutRoomParticipantItem from './BreakoutRoomParticipantItem';
13
+import styles from './styles';
14
+
15
+type Props = {
16
+
17
+    /**
18
+     * Room to display.
19
+     */
20
+    room: Object
21
+}
22
+
23
+/**
24
+ * Returns a key for a passed item of the list.
25
+ *
26
+ * @param {Object} item - The participant.
27
+ * @returns {string} - The user ID.
28
+ */
29
+function _keyExtractor(item: Object) {
30
+    return item.jid;
31
+}
32
+
33
+
34
+export const CollapsibleRoom = ({ room }: Props) => {
35
+    const dispatch = useDispatch();
36
+    const [ collapsed, setCollapsed ] = useState(false);
37
+    const { t } = useTranslation();
38
+    const _toggleCollapsed = useCallback(() => {
39
+        setCollapsed(!collapsed);
40
+    }, [ collapsed ]);
41
+    const _openContextMenu = useCallback(() => {
42
+        dispatch(openDialog(BreakoutRoomContextMenu, { room }));
43
+    }, [ room ]);
44
+
45
+    return (
46
+        <View>
47
+            <TouchableOpacity
48
+                onLongPress = { _openContextMenu }
49
+                onPress = { _toggleCollapsed }
50
+                style = { styles.collapsibleRoom }>
51
+                <TouchableOpacity
52
+                    onPress = { _toggleCollapsed }
53
+                    style = { styles.arrowIcon }>
54
+                    <Icon
55
+                        size = { 18 }
56
+                        src = { collapsed ? IconArrowDown : IconArrowUp } />
57
+                </TouchableOpacity>
58
+                <Text style = { styles.roomName }>
59
+                    {`${room.name || t('breakoutRooms.mainRoom')} (${Object.values(room.participants || {}).length})`}
60
+                </Text>
61
+            </TouchableOpacity>
62
+            {!collapsed && <FlatList
63
+                bounces = { false }
64
+                data = { Object.values(room.participants || {}) }
65
+                horizontal = { false }
66
+                keyExtractor = { _keyExtractor }
67
+                renderItem = { BreakoutRoomParticipantItem }
68
+                showsHorizontalScrollIndicator = { false }
69
+                windowSize = { 2 } />}
70
+        </View>
71
+    );
72
+};

+ 31
- 0
react/features/breakout-rooms/components/native/LeaveBreakoutRoomButton.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { Button } from 'react-native-paper';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import { moveToRoom } from '../../actions';
9
+
10
+import styles from './styles';
11
+
12
+const LeaveBreakoutRoomButton = () => {
13
+    const { t } = useTranslation();
14
+    const dispatch = useDispatch();
15
+
16
+    const onLeave = useCallback(() =>
17
+        dispatch(moveToRoom())
18
+    , [ dispatch ]);
19
+
20
+    return (
21
+        <Button
22
+            accessibilityLabel = { t('breakoutRooms.actions.leaveBreakoutRoom') }
23
+            children = { t('breakoutRooms.actions.leaveBreakoutRoom') }
24
+            labelStyle = { styles.leaveButtonLabel }
25
+            mode = 'contained'
26
+            onPress = { onLeave }
27
+            style = { styles.transparentButton } />
28
+    );
29
+};
30
+
31
+export default LeaveBreakoutRoomButton;

+ 5
- 0
react/features/breakout-rooms/components/native/index.js View File

1
+// @flow
2
+
3
+export { default as AddBreakoutRoomButton } from './AddBreakoutRoomButton';
4
+export { default as AutoAssignButton } from './AutoAssignButton';
5
+export { default as LeaveBreakoutRoomButton } from './LeaveBreakoutRoomButton';

+ 69
- 0
react/features/breakout-rooms/components/native/styles.js View File

1
+import BaseTheme from '../../../base/ui/components/BaseTheme.native';
2
+
3
+const baseButton = {
4
+    height: BaseTheme.spacing[6],
5
+    marginTop: BaseTheme.spacing[2],
6
+    marginLeft: BaseTheme.spacing[3],
7
+    marginRight: BaseTheme.spacing[3]
8
+};
9
+
10
+const baseLabel = {
11
+    fontSize: 15,
12
+    lineHeight: 24,
13
+    textTransform: 'capitalize'
14
+};
15
+
16
+/**
17
+ * The styles of the native components of the feature {@code breakout rooms}.
18
+ */
19
+export default {
20
+
21
+    addButtonLabel: {
22
+        ...baseLabel,
23
+        color: BaseTheme.palette.text01
24
+    },
25
+
26
+    addButton: {
27
+        ...baseButton,
28
+        backgroundColor: BaseTheme.palette.ui03
29
+    },
30
+
31
+    collapsibleRoom: {
32
+        ...baseButton,
33
+        display: 'flex',
34
+        flexDirection: 'row',
35
+        alignItems: 'center'
36
+    },
37
+
38
+    arrowIcon: {
39
+        backgroundColor: BaseTheme.palette.ui03,
40
+        height: BaseTheme.spacing[5],
41
+        width: BaseTheme.spacing[5],
42
+        borderRadius: 6,
43
+        display: 'flex',
44
+        alignItems: 'center',
45
+        justifyContent: 'center'
46
+    },
47
+
48
+    roomName: {
49
+        fontSize: 15,
50
+        color: BaseTheme.palette.text01,
51
+        fontWeight: 'bold',
52
+        marginLeft: BaseTheme.spacing[2]
53
+    },
54
+
55
+    transparentButton: {
56
+        ...baseButton,
57
+        backgroundColor: 'transparent'
58
+    },
59
+
60
+    leaveButtonLabel: {
61
+        ...baseLabel,
62
+        color: BaseTheme.palette.textError
63
+    },
64
+
65
+    autoAssignLabel: {
66
+        ...baseLabel,
67
+        color: BaseTheme.palette.link01
68
+    }
69
+};

+ 36
- 0
react/features/breakout-rooms/components/web/AddBreakoutRoomButton.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/core/styles';
4
+import React, { useCallback } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
9
+import { createBreakoutRoom } from '../../actions';
10
+
11
+const useStyles = makeStyles(() => {
12
+    return {
13
+        button: {
14
+            width: '100%'
15
+        }
16
+    };
17
+});
18
+
19
+export const AddBreakoutRoomButton = () => {
20
+    const { t } = useTranslation();
21
+    const dispatch = useDispatch();
22
+    const styles = useStyles();
23
+
24
+    const onAdd = useCallback(() =>
25
+        dispatch(createBreakoutRoom())
26
+    , [ dispatch ]);
27
+
28
+    return (
29
+        <ParticipantPaneBaseButton
30
+            accessibilityLabel = { t('breakoutRooms.actions.add') }
31
+            className = { styles.button }
32
+            onClick = { onAdd }>
33
+            {t('breakoutRooms.actions.add')}
34
+        </ParticipantPaneBaseButton>
35
+    );
36
+};

+ 42
- 0
react/features/breakout-rooms/components/web/AutoAssignButton.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/styles';
4
+import React, { useCallback } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
9
+import { autoAssignToBreakoutRooms } from '../../actions';
10
+
11
+const useStyles = makeStyles(theme => {
12
+    return {
13
+        button: {
14
+            color: theme.palette.link01,
15
+            width: '100%',
16
+            backgroundColor: 'transparent',
17
+
18
+            '&:hover': {
19
+                backgroundColor: 'transparent'
20
+            }
21
+        }
22
+    };
23
+});
24
+
25
+export const AutoAssignButton = () => {
26
+    const { t } = useTranslation();
27
+    const dispatch = useDispatch();
28
+    const styles = useStyles();
29
+
30
+    const onAutoAssign = useCallback(() => {
31
+        dispatch(autoAssignToBreakoutRooms());
32
+    }, [ dispatch ]);
33
+
34
+    return (
35
+        <ParticipantPaneBaseButton
36
+            accessibilityLabel = { t('breakoutRooms.actions.autoAssign') }
37
+            className = { styles.button }
38
+            onClick = { onAutoAssign }>
39
+            {t('breakoutRooms.actions.autoAssign')}
40
+        </ParticipantPaneBaseButton>
41
+    );
42
+};

+ 127
- 0
react/features/breakout-rooms/components/web/CollapsibleRoom.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/styles';
4
+import clsx from 'clsx';
5
+import React, { useCallback, useState } from 'react';
6
+import { useTranslation } from 'react-i18next';
7
+
8
+import { ListItem } from '../../../base/components';
9
+import { Icon, IconArrowDown, IconArrowUp } from '../../../base/icons';
10
+import ParticipantItem from '../../../participants-pane/components/web/ParticipantItem';
11
+import { ACTION_TRIGGER } from '../../../participants-pane/constants';
12
+
13
+type Props = {
14
+
15
+    /**
16
+     * Type of trigger for the breakout room actions.
17
+     */
18
+    actionsTrigger?: string,
19
+
20
+    /**
21
+     * React children.
22
+     */
23
+    children: React$Node,
24
+
25
+    /**
26
+     * Is this item highlighted/raised.
27
+     */
28
+    isHighlighted?: boolean,
29
+
30
+    /**
31
+     * Callback to raise menu. Used to raise menu on mobile long press.
32
+     */
33
+    onRaiseMenu: Function,
34
+
35
+    /**
36
+     * Callback for when the mouse leaves this component.
37
+     */
38
+    onLeave?: Function,
39
+
40
+    /**
41
+     * Room reference.
42
+     */
43
+    room: Object,
44
+}
45
+
46
+const useStyles = makeStyles(theme => {
47
+    return {
48
+        container: {
49
+            boxShadow: 'none'
50
+        },
51
+
52
+        roomName: {
53
+            overflow: 'hidden',
54
+            textOverflow: 'ellipsis',
55
+            whiteSpace: 'nowrap',
56
+            ...theme.typography.labelButton,
57
+            lineHeight: `${theme.typography.labelButton.lineHeight}px`,
58
+            padding: '12px 0'
59
+        },
60
+
61
+        arrowContainer: {
62
+            backgroundColor: theme.palette.ui03,
63
+            width: '24px',
64
+            height: '24px',
65
+            borderRadius: '6px',
66
+            marginRight: '16px',
67
+            display: 'flex',
68
+            alignItems: 'center',
69
+            justifyContent: 'center'
70
+        }
71
+    };
72
+});
73
+
74
+export const CollapsibleRoom = ({
75
+    actionsTrigger = ACTION_TRIGGER.HOVER,
76
+    children,
77
+    isHighlighted,
78
+    onRaiseMenu,
79
+    onLeave,
80
+    room
81
+}: Props) => {
82
+    const { t } = useTranslation();
83
+    const styles = useStyles();
84
+    const [ collapsed, setCollapsed ] = useState(false);
85
+    const toggleCollapsed = useCallback(() => {
86
+        setCollapsed(!collapsed);
87
+    }, [ collapsed ]);
88
+    const raiseMenu = useCallback(target => {
89
+        onRaiseMenu(target);
90
+    }, [ onRaiseMenu ]);
91
+
92
+    const arrow = (<div className = { styles.arrowContainer }>
93
+        <Icon
94
+            size = { 14 }
95
+            src = { collapsed ? IconArrowDown : IconArrowUp } />
96
+    </div>);
97
+
98
+    const roomName = (<span className = { styles.roomName }>
99
+        {`${room.name || t('breakoutRooms.mainRoom')} (${Object.keys(room?.participants
100
+            || {}).length})`}
101
+    </span>);
102
+
103
+    return (
104
+        <>
105
+            <ListItem
106
+                actions = { children }
107
+                className = { clsx(styles.container, 'breakout-room-container') }
108
+                icon = { arrow }
109
+                isHighlighted = { isHighlighted }
110
+                onClick = { toggleCollapsed }
111
+                onLongPress = { raiseMenu }
112
+                onMouseLeave = { onLeave }
113
+                testId = { room.id }
114
+                textChildren = { roomName }
115
+                trigger = { actionsTrigger } />
116
+            {!collapsed && room?.participants
117
+                && Object.values(room?.participants || {}).map((p: Object) => (
118
+                    <ParticipantItem
119
+                        displayName = { p.displayName }
120
+                        key = { p.jid }
121
+                        local = { false }
122
+                        participantID = { p.jid } />
123
+                ))
124
+            }
125
+        </>
126
+    );
127
+};

+ 47
- 0
react/features/breakout-rooms/components/web/JoinQuickActionButton.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/styles';
4
+import React, { useCallback } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import { QuickActionButton } from '../../../base/components';
9
+import { moveToRoom } from '../../actions';
10
+
11
+type Props = {
12
+
13
+    /**
14
+     * The room to join.
15
+     */
16
+    room: Object
17
+}
18
+
19
+const useStyles = makeStyles(theme => {
20
+    return {
21
+        button: {
22
+            marginRight: `${theme.spacing(2)}px`
23
+        }
24
+    };
25
+});
26
+
27
+const JoinActionButton = ({ room }: Props) => {
28
+    const styles = useStyles();
29
+    const { t } = useTranslation();
30
+    const dispatch = useDispatch();
31
+
32
+    const onJoinRoom = useCallback(e => {
33
+        e.stopPropagation();
34
+        dispatch(moveToRoom(room.jid));
35
+    }, [ dispatch, room ]);
36
+
37
+    return (<QuickActionButton
38
+        accessibilityLabel = { t('breakoutRooms.actions.join') }
39
+        className = { styles.button }
40
+        onClick = { onJoinRoom }
41
+        testId = { `join-room-${room.id}` }>
42
+        {t('breakoutRooms.actions.join')}
43
+    </QuickActionButton>
44
+    );
45
+};
46
+
47
+export default JoinActionButton;

+ 42
- 0
react/features/breakout-rooms/components/web/LeaveButton.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/styles';
4
+import React, { useCallback } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch } from 'react-redux';
7
+
8
+import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
9
+import { moveToRoom } from '../../actions';
10
+
11
+const useStyles = makeStyles(theme => {
12
+    return {
13
+        button: {
14
+            color: theme.palette.textError,
15
+            backgroundColor: 'transparent',
16
+            width: '100%',
17
+
18
+            '&:hover': {
19
+                backgroundColor: 'transparent'
20
+            }
21
+        }
22
+    };
23
+});
24
+
25
+export const LeaveButton = () => {
26
+    const { t } = useTranslation();
27
+    const dispatch = useDispatch();
28
+    const styles = useStyles();
29
+
30
+    const onLeave = useCallback(() => {
31
+        dispatch(moveToRoom());
32
+    }, [ dispatch ]);
33
+
34
+    return (
35
+        <ParticipantPaneBaseButton
36
+            accessibilityLabel = { t('breakoutRooms.actions.leaveBreakoutRoom') }
37
+            className = { styles.button }
38
+            onClick = { onLeave }>
39
+            {t('breakoutRooms.actions.leaveBreakoutRoom')}
40
+        </ParticipantPaneBaseButton>
41
+    );
42
+};

+ 40
- 0
react/features/breakout-rooms/components/web/RoomActionEllipsis.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/styles';
4
+import React from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+
7
+import { QuickActionButton } from '../../../base/components';
8
+import { Icon, IconHorizontalPoints } from '../../../base/icons';
9
+
10
+type Props = {
11
+
12
+    /**
13
+     * Click handler function.
14
+     */
15
+    onClick: Function
16
+}
17
+
18
+const useStyles = makeStyles(() => {
19
+    return {
20
+        button: {
21
+            padding: '6px'
22
+        }
23
+    };
24
+});
25
+
26
+const RoomActionEllipsis = ({ onClick }: Props) => {
27
+    const styles = useStyles();
28
+    const { t } = useTranslation();
29
+
30
+    return (
31
+        <QuickActionButton
32
+            accessibilityLabel = { t('breakoutRooms.actions.more') }
33
+            className = { styles.button }
34
+            onClick = { onClick }>
35
+            <Icon src = { IconHorizontalPoints } />
36
+        </QuickActionButton>
37
+    );
38
+};
39
+
40
+export default RoomActionEllipsis;

+ 100
- 0
react/features/breakout-rooms/components/web/RoomContextMenu.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { useDispatch, useSelector } from 'react-redux';
6
+
7
+import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
8
+import {
9
+    IconClose,
10
+    IconRingGroup
11
+} from '../../../base/icons';
12
+import { isLocalParticipantModerator } from '../../../base/participants';
13
+import { showOverflowDrawer } from '../../../toolbox/functions.web';
14
+import { closeBreakoutRoom, moveToRoom, removeBreakoutRoom } from '../../actions';
15
+
16
+type Props = {
17
+
18
+    /**
19
+         * Room reference.
20
+         */
21
+    entity: Object,
22
+
23
+    /**
24
+     * Target elements against which positioning calculations are made.
25
+     */
26
+    offsetTarget: HTMLElement,
27
+
28
+    /**
29
+     * Callback for the mouse entering the component.
30
+     */
31
+    onEnter: Function,
32
+
33
+    /**
34
+     * Callback for the mouse leaving the component.
35
+     */
36
+    onLeave: Function,
37
+
38
+    /**
39
+     * Callback for making a selection in the menu.
40
+     */
41
+    onSelect: Function
42
+};
43
+
44
+export const RoomContextMenu = ({
45
+    entity: room,
46
+    offsetTarget,
47
+    onEnter,
48
+    onLeave,
49
+    onSelect
50
+}: Props) => {
51
+    const dispatch = useDispatch();
52
+    const { t } = useTranslation();
53
+    const isLocalModerator = useSelector(isLocalParticipantModerator);
54
+    const _overflowDrawer = useSelector(showOverflowDrawer);
55
+
56
+    const onJoinRoom = useCallback(() => {
57
+        dispatch(moveToRoom(room.id));
58
+    }, [ dispatch, room ]);
59
+
60
+    const onRemoveBreakoutRoom = useCallback(() => {
61
+        dispatch(removeBreakoutRoom(room.jid));
62
+    }, [ dispatch, room ]);
63
+
64
+    const onCloseBreakoutRoom = useCallback(() => {
65
+        dispatch(closeBreakoutRoom(room.id));
66
+    }, [ dispatch, room ]);
67
+
68
+    const isRoomEmpty = !(room?.participants && Object.keys(room.participants).length > 0);
69
+
70
+    const actions = [
71
+        _overflowDrawer ? {
72
+            accessibilityLabel: t('breakoutRooms.actions.join'),
73
+            icon: IconRingGroup,
74
+            onClick: onJoinRoom,
75
+            text: t('breakoutRooms.actions.join')
76
+        } : null,
77
+        !room?.isMainRoom && isLocalModerator ? {
78
+            accessibilityLabel: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close'),
79
+            icon: IconClose,
80
+            id: isRoomEmpty ? `remove-room-${room?.id}` : `close-room-${room?.id}`,
81
+            onClick: isRoomEmpty ? onRemoveBreakoutRoom : onCloseBreakoutRoom,
82
+            text: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close')
83
+        } : null
84
+    ].filter(Boolean);
85
+
86
+    const lowerMenu = useCallback(() => onSelect(true));
87
+
88
+    return (
89
+        <ContextMenu
90
+            entity = { room }
91
+            isDrawerOpen = { room }
92
+            offsetTarget = { offsetTarget }
93
+            onClick = { lowerMenu }
94
+            onDrawerClose = { onSelect }
95
+            onMouseEnter = { onEnter }
96
+            onMouseLeave = { onLeave }>
97
+            <ContextMenuItemGroup actions = { actions } />
98
+        </ContextMenu>
99
+    );
100
+};

+ 63
- 0
react/features/breakout-rooms/components/web/RoomList.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useSelector } from 'react-redux';
5
+
6
+import useContextMenu from '../../../base/components/context-menu/useContextMenu';
7
+import { getParticipantCount, isLocalParticipantModerator } from '../../../base/participants';
8
+import { equals } from '../../../base/redux';
9
+import { showOverflowDrawer } from '../../../toolbox/functions.web';
10
+import { getBreakoutRooms, isInBreakoutRoom, getCurrentRoomId } from '../../functions';
11
+
12
+import { AutoAssignButton } from './AutoAssignButton';
13
+import { CollapsibleRoom } from './CollapsibleRoom';
14
+import JoinActionButton from './JoinQuickActionButton';
15
+import { LeaveButton } from './LeaveButton';
16
+import RoomActionEllipsis from './RoomActionEllipsis';
17
+import { RoomContextMenu } from './RoomContextMenu';
18
+
19
+export const RoomList = () => {
20
+    const currentRoomId = useSelector(getCurrentRoomId);
21
+    const rooms = Object.values(useSelector(getBreakoutRooms, equals))
22
+                    .filter((room: Object) => room.id !== currentRoomId)
23
+                    .sort((p1: Object, p2: Object) => (p1?.name || '').localeCompare(p2?.name || ''));
24
+    const inBreakoutRoom = useSelector(isInBreakoutRoom);
25
+    const isLocalModerator = useSelector(isLocalParticipantModerator);
26
+    const participantsCount = useSelector(getParticipantCount);
27
+    const _overflowDrawer = useSelector(showOverflowDrawer);
28
+    const [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu();
29
+
30
+    const onRaiseMenu = useCallback(room => target => raiseMenu(room, target), [ raiseMenu ]);
31
+
32
+    return (
33
+        <>
34
+            {inBreakoutRoom && <LeaveButton />}
35
+            {!inBreakoutRoom
36
+                && isLocalModerator
37
+                && participantsCount > 2
38
+                && rooms.length > 1
39
+                && <AutoAssignButton />}
40
+            <div id = 'breakout-rooms-list'>
41
+                {rooms.map((room: Object) => (
42
+                    <React.Fragment key = { room.id }>
43
+                        <CollapsibleRoom
44
+                            isHighlighted = { raiseContext.entity === room }
45
+                            onLeave = { lowerMenu }
46
+                            onRaiseMenu = { onRaiseMenu(room) }
47
+                            room = { room }>
48
+                            {!_overflowDrawer && <>
49
+                                <JoinActionButton room = { room } />
50
+                                {isLocalModerator && <RoomActionEllipsis onClick = { toggleMenu(room) } />}
51
+                            </>}
52
+                        </CollapsibleRoom>
53
+                    </React.Fragment>
54
+                ))}
55
+            </div>
56
+            <RoomContextMenu
57
+                onEnter = { menuEnter }
58
+                onLeave = { menuLeave }
59
+                onSelect = { lowerMenu }
60
+                { ...raiseContext } />
61
+        </>
62
+    );
63
+};

+ 4
- 0
react/features/breakout-rooms/components/web/index.js View File

1
+// @flow
2
+
3
+export * from './LeaveButton';
4
+export * from './RoomList';

+ 22
- 0
react/features/breakout-rooms/constants.js View File

1
+// @flow
2
+
3
+/**
4
+ * Key for this feature.
5
+ */
6
+export const FEATURE_KEY = 'features/breakout-rooms';
7
+
8
+/**
9
+  * The type of json-message which indicates that json carries
10
+  * a request for a participant to move to a specified room.
11
+  */
12
+export const JSON_TYPE_MOVE_TO_ROOM_REQUEST = `${FEATURE_KEY}/move-to-room`;
13
+
14
+/**
15
+  * The type of json-message which indicates that json carries a request to remove a specified breakout room.
16
+  */
17
+export const JSON_TYPE_REMOVE_BREAKOUT_ROOM = `${FEATURE_KEY}/remove`;
18
+
19
+/**
20
+  * The type of json-message which indicates that json carries breakout rooms data.
21
+  */
22
+export const JSON_TYPE_UPDATE_BREAKOUT_ROOMS = `${FEATURE_KEY}/update`;

+ 59
- 0
react/features/breakout-rooms/functions.js View File

1
+// @flow
2
+
3
+import _ from 'lodash';
4
+
5
+import { getCurrentConference } from '../base/conference';
6
+import { toState } from '../base/redux';
7
+
8
+import { FEATURE_KEY } from './constants';
9
+
10
+/**
11
+ * Returns the rooms object for breakout rooms.
12
+ *
13
+ * @param {Function|Object} stateful - The redux store, the redux
14
+ * {@code getState} function, or the redux state itself.
15
+ * @returns {Object} Object of rooms.
16
+ */
17
+export const getBreakoutRooms = (stateful: Function | Object) => toState(stateful)[FEATURE_KEY].rooms;
18
+
19
+/**
20
+ * Returns the main room.
21
+ *
22
+ * @param {Function|Object} stateful - The redux store, the redux
23
+ * {@code getState} function, or the redux state itself.
24
+ * @returns {Object|undefined} The main room object, or undefined.
25
+ */
26
+export const getMainRoom = (stateful: Function | Object) => {
27
+    const rooms = getBreakoutRooms(stateful);
28
+
29
+    return _.find(rooms, (room: Object) => room.isMainRoom);
30
+};
31
+
32
+/**
33
+ * Returns the id of the current room.
34
+ *
35
+ * @param {Function|Object} stateful - The redux store, the redux
36
+ * {@code getState} function, or the redux state itself.
37
+ * @returns {string} Room id or undefined.
38
+ */
39
+export const getCurrentRoomId = (stateful: Function | Object) => {
40
+    const conference = getCurrentConference(stateful);
41
+
42
+    // $FlowExpectedError
43
+    return conference?.getName();
44
+};
45
+
46
+/**
47
+ * Determines whether the local participant is in a breakout room.
48
+ *
49
+ * @param {Function|Object} stateful - The redux store, the redux
50
+ * {@code getState} function, or the redux state itself.
51
+ * @returns {boolean}
52
+ */
53
+export const isInBreakoutRoom = (stateful: Function | Object) => {
54
+    const conference = getCurrentConference(stateful);
55
+
56
+    // $FlowExpectedError
57
+    return conference?.getBreakoutRooms()
58
+        ?.isBreakoutRoom();
59
+};

+ 7
- 0
react/features/breakout-rooms/logger.js View File

1
+// @flow
2
+
3
+import { getLogger } from '../base/logging/functions';
4
+
5
+import { FEATURE_KEY } from './constants';
6
+
7
+export default getLogger(FEATURE_KEY);

+ 31
- 0
react/features/breakout-rooms/middleware.js View File

1
+// @flow
2
+
3
+import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
4
+import { StateListenerRegistry } from '../base/redux';
5
+
6
+import { UPDATE_BREAKOUT_ROOMS } from './actionTypes';
7
+import { moveToRoom } from './actions';
8
+import logger from './logger';
9
+
10
+/**
11
+ * Registers a change handler for state['features/base/conference'].conference to
12
+ * set the event listeners needed for the breakout rooms feature to operate.
13
+ */
14
+StateListenerRegistry.register(
15
+    state => state['features/base/conference'].conference,
16
+    (conference, { dispatch }, previousConference) => {
17
+        if (conference && !previousConference) {
18
+            conference.on(JitsiConferenceEvents.BREAKOUT_ROOMS_MOVE_TO_ROOM, roomId => {
19
+                logger.debug(`Moving to room: ${roomId}`);
20
+                dispatch(moveToRoom(roomId));
21
+            });
22
+
23
+            conference.on(JitsiConferenceEvents.BREAKOUT_ROOMS_UPDATED, rooms => {
24
+                logger.debug('Room list updated');
25
+                dispatch({
26
+                    type: UPDATE_BREAKOUT_ROOMS,
27
+                    rooms
28
+                });
29
+            });
30
+        }
31
+    });

+ 25
- 0
react/features/breakout-rooms/reducer.js View File

1
+// @flow
2
+
3
+import { ReducerRegistry } from '../base/redux';
4
+
5
+import { UPDATE_BREAKOUT_ROOMS } from './actionTypes';
6
+import { FEATURE_KEY } from './constants';
7
+
8
+/**
9
+ * Listen for actions for the breakout-rooms feature.
10
+ */
11
+ReducerRegistry.register(FEATURE_KEY, (state = { rooms: {} }, action) => {
12
+    switch (action.type) {
13
+    case UPDATE_BREAKOUT_ROOMS: {
14
+        const { nextIndex, rooms } = action;
15
+
16
+        return {
17
+            ...state,
18
+            nextIndex,
19
+            rooms
20
+        };
21
+    }
22
+    }
23
+
24
+    return state;
25
+});

+ 16
- 2
react/features/conference/components/native/LonelyMeetingExperience.js View File

10
 import { getParticipantCountWithFake } from '../../../base/participants';
10
 import { getParticipantCountWithFake } from '../../../base/participants';
11
 import { connect } from '../../../base/redux';
11
 import { connect } from '../../../base/redux';
12
 import { StyleType } from '../../../base/styles';
12
 import { StyleType } from '../../../base/styles';
13
+import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
13
 import { doInvitePeople } from '../../../invite/actions.native';
14
 import { doInvitePeople } from '../../../invite/actions.native';
14
 
15
 
15
 import styles from './styles';
16
 import styles from './styles';
19
  */
20
  */
20
 type Props = {
21
 type Props = {
21
 
22
 
23
+    /**
24
+     * True if currently in a breakout room.
25
+     */
26
+     _isInBreakoutRoom: boolean,
27
+
22
     /**
28
     /**
23
      * True if the invite functions (dial out, invite, share...etc) are disabled.
29
      * True if the invite functions (dial out, invite, share...etc) are disabled.
24
      */
30
      */
66
      * @inheritdoc
72
      * @inheritdoc
67
      */
73
      */
68
     render() {
74
     render() {
69
-        const { _isInviteFunctionsDiabled, _isLonelyMeeting, _styles, t } = this.props;
75
+        const {
76
+            _isInBreakoutRoom,
77
+            _isInviteFunctionsDiabled,
78
+            _isLonelyMeeting,
79
+            _styles,
80
+            t
81
+        } = this.props;
70
 
82
 
71
         if (!_isLonelyMeeting) {
83
         if (!_isLonelyMeeting) {
72
             return null;
84
             return null;
81
                     ] }>
93
                     ] }>
82
                     { t('lonelyMeetingExperience.youAreAlone') }
94
                     { t('lonelyMeetingExperience.youAreAlone') }
83
                 </Text>
95
                 </Text>
84
-                { !_isInviteFunctionsDiabled && (
96
+                { !_isInviteFunctionsDiabled && !_isInBreakoutRoom && (
85
                     <TouchableOpacity
97
                     <TouchableOpacity
86
                         onPress = { this._onPress }
98
                         onPress = { this._onPress }
87
                         style = { [
99
                         style = { [
128
     const { disableInviteFunctions } = state['features/base/config'];
140
     const { disableInviteFunctions } = state['features/base/config'];
129
     const { conference } = state['features/base/conference'];
141
     const { conference } = state['features/base/conference'];
130
     const flag = getFeatureFlag(state, INVITE_ENABLED, true);
142
     const flag = getFeatureFlag(state, INVITE_ENABLED, true);
143
+    const _isInBreakoutRoom = isInBreakoutRoom(state);
131
 
144
 
132
     return {
145
     return {
146
+        _isInBreakoutRoom,
133
         _isInviteFunctionsDiabled: !flag || disableInviteFunctions,
147
         _isInviteFunctionsDiabled: !flag || disableInviteFunctions,
134
         _isLonelyMeeting: conference && getParticipantCountWithFake(state) === 1,
148
         _isLonelyMeeting: conference && getParticipantCountWithFake(state) === 1,
135
         _styles: ColorSchemeRegistry.get(state, 'Conference')
149
         _styles: ColorSchemeRegistry.get(state, 'Conference')

+ 15
- 2
react/features/participants-pane/components/native/MeetingParticipantList.js View File

9
 import { getLocalParticipant, getParticipantCountWithFake, getRemoteParticipants } from '../../../base/participants';
9
 import { getLocalParticipant, getParticipantCountWithFake, getRemoteParticipants } from '../../../base/participants';
10
 import { connect } from '../../../base/redux';
10
 import { connect } from '../../../base/redux';
11
 import { normalizeAccents } from '../../../base/util/strings';
11
 import { normalizeAccents } from '../../../base/util/strings';
12
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
12
 import { doInvitePeople } from '../../../invite/actions.native';
13
 import { doInvitePeople } from '../../../invite/actions.native';
13
 import { shouldRenderInviteButton } from '../../functions';
14
 import { shouldRenderInviteButton } from '../../functions';
14
 
15
 
19
 
20
 
20
 type Props = {
21
 type Props = {
21
 
22
 
23
+    /**
24
+     * Current breakout room, if we are in one.
25
+     */
26
+    _currentRoom: ?Object,
27
+
22
     /**
28
     /**
23
      * The local participant.
29
      * The local participant.
24
      */
30
      */
186
      */
192
      */
187
     render() {
193
     render() {
188
         const {
194
         const {
195
+            _currentRoom,
189
             _localParticipant,
196
             _localParticipant,
190
             _participantsCount,
197
             _participantsCount,
191
             _showInviteButton,
198
             _showInviteButton,
197
             <View
204
             <View
198
                 style = { styles.meetingListContainer }>
205
                 style = { styles.meetingListContainer }>
199
                 <Text style = { styles.meetingListDescription }>
206
                 <Text style = { styles.meetingListDescription }>
200
-                    {t('participantsPane.headings.participantsList',
201
-                        { count: _participantsCount })}
207
+                    {_currentRoom?.name
208
+
209
+                        // $FlowExpectedError
210
+                        ? `${_currentRoom.name} (${_participantsCount})`
211
+                        : t('participantsPane.headings.participantsList', { count: _participantsCount })}
202
                 </Text>
212
                 </Text>
203
                 {
213
                 {
204
                     _showInviteButton
214
                     _showInviteButton
241
     const { remoteParticipants } = state['features/filmstrip'];
251
     const { remoteParticipants } = state['features/filmstrip'];
242
     const _showInviteButton = shouldRenderInviteButton(state);
252
     const _showInviteButton = shouldRenderInviteButton(state);
243
     const _remoteParticipants = getRemoteParticipants(state);
253
     const _remoteParticipants = getRemoteParticipants(state);
254
+    const currentRoomId = getCurrentRoomId(state);
255
+    const _currentRoom = getBreakoutRooms(state)[currentRoomId];
244
 
256
 
245
     return {
257
     return {
258
+        _currentRoom,
246
         _participantsCount,
259
         _participantsCount,
247
         _remoteParticipants,
260
         _remoteParticipants,
248
         _showInviteButton,
261
         _showInviteButton,

+ 5
- 4
react/features/participants-pane/components/native/ParticipantItem.js View File

17
     /**
17
     /**
18
      * Media state for audio.
18
      * Media state for audio.
19
      */
19
      */
20
-    audioMediaState: MediaState,
20
+    audioMediaState?: MediaState,
21
 
21
 
22
     /**
22
     /**
23
      * React children.
23
      * React children.
47
     /**
47
     /**
48
      * True if the participant is local.
48
      * True if the participant is local.
49
      */
49
      */
50
-    local: boolean,
50
+    local?: boolean,
51
 
51
 
52
     /**
52
     /**
53
      * Callback to be invoked on pressing the participant item.
53
      * Callback to be invoked on pressing the participant item.
62
     /**
62
     /**
63
      * True if the participant have raised hand.
63
      * True if the participant have raised hand.
64
      */
64
      */
65
-    raisedHand: boolean,
65
+    raisedHand?: boolean,
66
 
66
 
67
     /**
67
     /**
68
      * Media state for video.
68
      * Media state for video.
69
      */
69
      */
70
-    videoMediaState: MediaState
70
+    videoMediaState?: MediaState
71
 }
71
 }
72
 
72
 
73
 /**
73
 /**
98
                 style = { styles.participantContent }>
98
                 style = { styles.participantContent }>
99
                 <Avatar
99
                 <Avatar
100
                     className = 'participant-avatar'
100
                     className = 'participant-avatar'
101
+                    displayName = { displayName }
101
                     participantId = { participantID }
102
                     participantId = { participantID }
102
                     size = { 32 } />
103
                     size = { 32 } />
103
                 <View style = { styles.participantDetailsContainer }>
104
                 <View style = { styles.participantDetailsContainer }>

+ 33
- 0
react/features/participants-pane/components/native/ParticipantsPane.js View File

9
 import { openDialog } from '../../../base/dialog';
9
 import { openDialog } from '../../../base/dialog';
10
 import JitsiScreen from '../../../base/modal/components/JitsiScreen';
10
 import JitsiScreen from '../../../base/modal/components/JitsiScreen';
11
 import {
11
 import {
12
+    getParticipantCount,
12
     isLocalParticipantModerator
13
     isLocalParticipantModerator
13
 } from '../../../base/participants';
14
 } from '../../../base/participants';
15
+import { equals } from '../../../base/redux';
16
+import {
17
+    AddBreakoutRoomButton,
18
+    AutoAssignButton,
19
+    LeaveBreakoutRoomButton
20
+} from '../../../breakout-rooms/components/native';
21
+import { CollapsibleRoom } from '../../../breakout-rooms/components/native/CollapsibleRoom';
22
+import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
14
 import MuteEveryoneDialog
23
 import MuteEveryoneDialog
15
     from '../../../video-menu/components/native/MuteEveryoneDialog';
24
     from '../../../video-menu/components/native/MuteEveryoneDialog';
16
 
25
 
33
         [ dispatch ]);
42
         [ dispatch ]);
34
     const { t } = useTranslation();
43
     const { t } = useTranslation();
35
 
44
 
45
+    const { hideAddRoomButton } = useSelector(state => state['features/base/config']);
46
+    const { conference } = useSelector(state => state['features/base/conference']);
47
+
48
+    // $FlowExpectedError
49
+    const _isBreakoutRoomsSupported = conference?.getBreakoutRooms()?.isSupported();
50
+    const currentRoomId = useSelector(getCurrentRoomId);
51
+    const rooms: Array<Object> = Object.values(useSelector(getBreakoutRooms, equals))
52
+        .filter((room: Object) => room.id !== currentRoomId)
53
+        .sort((p1: Object, p2: Object) => (p1?.name || '').localeCompare(p2?.name || ''));
54
+    const inBreakoutRoom = useSelector(isInBreakoutRoom);
55
+    const participantsCount = useSelector(getParticipantCount);
56
+
36
     return (
57
     return (
37
         <JitsiScreen
58
         <JitsiScreen
38
             style = { styles.participantsPane }>
59
             style = { styles.participantsPane }>
39
             <ScrollView bounces = { false }>
60
             <ScrollView bounces = { false }>
40
                 <LobbyParticipantList />
61
                 <LobbyParticipantList />
41
                 <MeetingParticipantList />
62
                 <MeetingParticipantList />
63
+                {!inBreakoutRoom
64
+                    && isLocalModerator
65
+                    && participantsCount > 2
66
+                    && rooms.length > 1
67
+                    && <AutoAssignButton />}
68
+                {inBreakoutRoom && <LeaveBreakoutRoomButton />}
69
+                {_isBreakoutRoomsSupported
70
+                    && rooms.map(room => (<CollapsibleRoom
71
+                        key = { room.id }
72
+                        room = { room } />))}
73
+                {_isBreakoutRoomsSupported && !hideAddRoomButton && isLocalModerator
74
+                    && <AddBreakoutRoomButton />}
42
             </ScrollView>
75
             </ScrollView>
43
             {
76
             {
44
                 isLocalModerator
77
                 isLocalModerator

+ 1
- 2
react/features/participants-pane/components/web/FooterContextMenu.js View File

15
     isEnabled as isAvModerationEnabled,
15
     isEnabled as isAvModerationEnabled,
16
     isSupported as isAvModerationSupported
16
     isSupported as isAvModerationSupported
17
 } from '../../../av-moderation/functions';
17
 } from '../../../av-moderation/functions';
18
-import ContextMenu from '../../../base/components/context-menu/ContextMenu';
19
-import ContextMenuItemGroup from '../../../base/components/context-menu/ContextMenuItemGroup';
18
+import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
20
 import { openDialog } from '../../../base/dialog';
19
 import { openDialog } from '../../../base/dialog';
21
 import { IconCheck, IconVideoOff } from '../../../base/icons';
20
 import { IconCheck, IconVideoOff } from '../../../base/icons';
22
 import { MEDIA_TYPE } from '../../../base/media';
21
 import { MEDIA_TYPE } from '../../../base/media';

+ 1
- 1
react/features/participants-pane/components/web/LobbyParticipantQuickAction.js View File

3
 import { makeStyles } from '@material-ui/styles';
3
 import { makeStyles } from '@material-ui/styles';
4
 import React from 'react';
4
 import React from 'react';
5
 
5
 
6
-import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
6
+import { QuickActionButton } from '../../../base/components';
7
 
7
 
8
 type Props = {
8
 type Props = {
9
 
9
 

+ 79
- 5
react/features/participants-pane/components/web/MeetingParticipantContextMenu.js View File

1
 // @flow
1
 // @flow
2
+import { withStyles } from '@material-ui/styles';
2
 import React, { Component } from 'react';
3
 import React, { Component } from 'react';
3
 
4
 
4
 import { approveParticipant } from '../../../av-moderation/actions';
5
 import { approveParticipant } from '../../../av-moderation/actions';
5
 import { Avatar } from '../../../base/avatar';
6
 import { Avatar } from '../../../base/avatar';
6
-import ContextMenu from '../../../base/components/context-menu/ContextMenu';
7
-import ContextMenuItemGroup from '../../../base/components/context-menu/ContextMenuItemGroup';
7
+import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
8
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
8
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
9
 import { openDialog } from '../../../base/dialog';
9
 import { openDialog } from '../../../base/dialog';
10
 import { isIosMobileBrowser } from '../../../base/environment/utils';
10
 import { isIosMobileBrowser } from '../../../base/environment/utils';
16
     IconMicDisabled,
16
     IconMicDisabled,
17
     IconMicrophone,
17
     IconMicrophone,
18
     IconMuteEveryoneElse,
18
     IconMuteEveryoneElse,
19
+    IconRingGroup,
19
     IconShareVideo,
20
     IconShareVideo,
20
     IconVideoOff
21
     IconVideoOff
21
 } from '../../../base/icons';
22
 } from '../../../base/icons';
28
 } from '../../../base/participants';
29
 } from '../../../base/participants';
29
 import { connect } from '../../../base/redux';
30
 import { connect } from '../../../base/redux';
30
 import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
31
 import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
32
+import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
33
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
31
 import { openChatById } from '../../../chat/actions';
34
 import { openChatById } from '../../../chat/actions';
32
 import { setVolume } from '../../../filmstrip/actions.web';
35
 import { setVolume } from '../../../filmstrip/actions.web';
33
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
36
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
42
      */
45
      */
43
     _isAudioForceMuted: boolean,
46
     _isAudioForceMuted: boolean,
44
 
47
 
48
+    /**
49
+     * The id of the current room.
50
+     */
51
+    _currentRoomId: String,
52
+
45
     /**
53
     /**
46
      * True if the local participant is moderator and false otherwise.
54
      * True if the local participant is moderator and false otherwise.
47
      */
55
      */
82
      */
90
      */
83
     _participant: Object,
91
     _participant: Object,
84
 
92
 
93
+    /**
94
+     * Rooms reference.
95
+     */
96
+    _rooms: Array<Object>,
97
+
85
     /**
98
     /**
86
      * A value between 0 and 1 indicating the volume of the participant's
99
      * A value between 0 and 1 indicating the volume of the participant's
87
      * audio element.
100
      * audio element.
117
     /**
130
     /**
118
      * Target elements against which positioning calculations are made.
131
      * Target elements against which positioning calculations are made.
119
      */
132
      */
120
-    offsetTarget: HTMLElement,
133
+    offsetTarget?: HTMLElement,
121
 
134
 
122
     /**
135
     /**
123
      * Callback for the mouse entering the component.
136
      * Callback for the mouse entering the component.
137
     /**
150
     /**
138
      * The ID of the participant.
151
      * The ID of the participant.
139
      */
152
      */
140
-    participantID: string,
153
+    participantID?: string,
141
 
154
 
142
     /**
155
     /**
143
      * True if an overflow drawer should be displayed.
156
      * True if an overflow drawer should be displayed.
150
     t: Function
163
     t: Function
151
 };
164
 };
152
 
165
 
166
+const styles = theme => {
167
+    return {
168
+        text: {
169
+            color: theme.palette.text02,
170
+            padding: '10px 16px',
171
+            height: '40px',
172
+            overflow: 'hidden',
173
+            display: 'flex',
174
+            alignItems: 'center',
175
+            boxSizing: 'border-box'
176
+        }
177
+    };
178
+};
179
+
153
 /**
180
 /**
154
  * Implements the MeetingParticipantContextMenu component.
181
  * Implements the MeetingParticipantContextMenu component.
155
  */
182
  */
170
         this._onMuteVideo = this._onMuteVideo.bind(this);
197
         this._onMuteVideo = this._onMuteVideo.bind(this);
171
         this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
198
         this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
172
         this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
199
         this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
200
+        this._onSendToRoom = this._onSendToRoom.bind(this);
173
         this._onVolumeChange = this._onVolumeChange.bind(this);
201
         this._onVolumeChange = this._onVolumeChange.bind(this);
174
         this._onAskToUnmute = this._onAskToUnmute.bind(this);
202
         this._onAskToUnmute = this._onAskToUnmute.bind(this);
175
     }
203
     }
265
         dispatch(openChatById(this._getCurrentParticipantId()));
293
         dispatch(openChatById(this._getCurrentParticipantId()));
266
     }
294
     }
267
 
295
 
296
+    _onSendToRoom: (room: Object) => void;
297
+
298
+    /**
299
+     * Sends a participant to a room.
300
+     *
301
+     * @param {Object} room - The room that the participant should be moved to.
302
+     * @returns {void}
303
+     */
304
+    _onSendToRoom(room: Object) {
305
+        return () => {
306
+            const { _participant, dispatch } = this.props;
307
+
308
+            dispatch(sendParticipantToRoom(_participant.id, room.id));
309
+        };
310
+    }
311
+
268
     _onVolumeChange: (number) => void;
312
     _onVolumeChange: (number) => void;
269
 
313
 
270
     /**
314
     /**
304
     render() {
348
     render() {
305
         const {
349
         const {
306
             _isAudioForceMuted,
350
             _isAudioForceMuted,
351
+            _currentRoomId,
307
             _isLocalModerator,
352
             _isLocalModerator,
308
             _isChatButtonEnabled,
353
             _isChatButtonEnabled,
309
             _isParticipantModerator,
354
             _isParticipantModerator,
312
             _isVideoForceMuted,
357
             _isVideoForceMuted,
313
             _localVideoOwner,
358
             _localVideoOwner,
314
             _participant,
359
             _participant,
360
+            _rooms,
315
             _volume = 1,
361
             _volume = 1,
362
+            classes,
316
             closeDrawer,
363
             closeDrawer,
317
             drawerParticipant,
364
             drawerParticipant,
318
             offsetTarget,
365
             offsetTarget,
391
             } : null
438
             } : null
392
         ].filter(Boolean);
439
         ].filter(Boolean);
393
 
440
 
441
+        const breakoutRoomActions = _rooms.map(room => {
442
+            if (room.id !== _currentRoomId) {
443
+                return {
444
+                    accessibilityLabel: room.name || t('breakoutRooms.mainRoom'),
445
+                    icon: IconRingGroup,
446
+                    onClick: this._onSendToRoom(room),
447
+                    text: room.name || t('breakoutRooms.mainRoom')
448
+                };
449
+            }
450
+
451
+            return null;
452
+        }
453
+        ).filter(Boolean);
454
+
394
         const actions
455
         const actions
395
             = _participant?.isFakeParticipant ? (
456
             = _participant?.isFakeParticipant ? (
396
                 <>
457
                 <>
406
                     }
467
                     }
407
 
468
 
408
                     <ContextMenuItemGroup actions = { moderatorActions2 } />
469
                     <ContextMenuItemGroup actions = { moderatorActions2 } />
470
+
471
+                    {
472
+                        _isLocalModerator && _rooms.length > 1
473
+                            && <ContextMenuItemGroup actions = { breakoutRoomActions } >
474
+                                <div className = { classes && classes.text }>
475
+                                    {t('breakoutRooms.actions.sendToBreakoutRoom')}
476
+                                </div>
477
+                            </ContextMenuItemGroup>
478
+                    }
409
                     { showVolumeSlider
479
                     { showVolumeSlider
410
                         && <ContextMenuItemGroup>
480
                         && <ContextMenuItemGroup>
411
                             <VolumeSlider
481
                             <VolumeSlider
456
     const participant = getParticipantByIdOrUndefined(state,
526
     const participant = getParticipantByIdOrUndefined(state,
457
         overflowDrawer ? drawerParticipant?.participantID : participantID);
527
         overflowDrawer ? drawerParticipant?.participantID : participantID);
458
 
528
 
529
+    const _currentRoomId = getCurrentRoomId(state);
459
     const _isLocalModerator = isLocalParticipantModerator(state);
530
     const _isLocalModerator = isLocalParticipantModerator(state);
460
     const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
531
     const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
461
     const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
532
     const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
462
     const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
533
     const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
463
     const _isParticipantModerator = isParticipantModerator(participant);
534
     const _isParticipantModerator = isParticipantModerator(participant);
535
+    const _rooms = Object.values(getBreakoutRooms(state));
464
 
536
 
465
     const { participantsVolume } = state['features/filmstrip'];
537
     const { participantsVolume } = state['features/filmstrip'];
466
     const id = participant?.id;
538
     const id = participant?.id;
468
 
540
 
469
     return {
541
     return {
470
         _isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
542
         _isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
543
+        _currentRoomId,
471
         _isLocalModerator,
544
         _isLocalModerator,
472
         _isChatButtonEnabled,
545
         _isChatButtonEnabled,
473
         _isParticipantModerator,
546
         _isParticipantModerator,
476
         _isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
549
         _isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
477
         _localVideoOwner: Boolean(ownerId === localParticipantId),
550
         _localVideoOwner: Boolean(ownerId === localParticipantId),
478
         _participant: participant,
551
         _participant: participant,
552
+        _rooms,
479
         _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined
553
         _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined
480
     };
554
     };
481
 }
555
 }
482
 
556
 
483
-export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu));
557
+export default translate(connect(_mapStateToProps)(withStyles(styles)(MeetingParticipantContextMenu)));

+ 1
- 2
react/features/participants-pane/components/web/MeetingParticipantItem.js View File

31
 import ParticipantItem from './ParticipantItem';
31
 import ParticipantItem from './ParticipantItem';
32
 import ParticipantQuickAction from './ParticipantQuickAction';
32
 import ParticipantQuickAction from './ParticipantQuickAction';
33
 
33
 
34
-
35
 type Props = {
34
 type Props = {
36
 
35
 
37
     /**
36
     /**
62
     /**
61
     /**
63
      * True if the participant is the local participant.
62
      * True if the participant is the local participant.
64
      */
63
      */
65
-    _local: Boolean,
64
+    _local: boolean,
66
 
65
 
67
     /**
66
     /**
68
      * Whether or not the local participant is moderator.
67
      * Whether or not the local participant is moderator.

+ 30
- 77
react/features/participants-pane/components/web/MeetingParticipants.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
 import { makeStyles } from '@material-ui/styles';
3
 import { makeStyles } from '@material-ui/styles';
4
-import React, { useCallback, useRef, useState } from 'react';
4
+import React, { useCallback, useState } from 'react';
5
 import { useTranslation } from 'react-i18next';
5
 import { useTranslation } from 'react-i18next';
6
 import { useDispatch } from 'react-redux';
6
 import { useDispatch } from 'react-redux';
7
 
7
 
8
 import { rejectParticipantAudio } from '../../../av-moderation/actions';
8
 import { rejectParticipantAudio } from '../../../av-moderation/actions';
9
+import useContextMenu from '../../../base/components/context-menu/useContextMenu';
9
 import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
10
 import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
10
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
11
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
11
 import { MEDIA_TYPE } from '../../../base/media';
12
 import { MEDIA_TYPE } from '../../../base/media';
14
 } from '../../../base/participants';
15
 } from '../../../base/participants';
15
 import { connect } from '../../../base/redux';
16
 import { connect } from '../../../base/redux';
16
 import { normalizeAccents } from '../../../base/util/strings';
17
 import { normalizeAccents } from '../../../base/util/strings';
18
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
17
 import { showOverflowDrawer } from '../../../toolbox/functions';
19
 import { showOverflowDrawer } from '../../../toolbox/functions';
18
 import { muteRemote } from '../../../video-menu/actions.any';
20
 import { muteRemote } from '../../../video-menu/actions.any';
19
-import { findAncestorByClass, getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
21
+import { getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
20
 import { useParticipantDrawer } from '../../hooks';
22
 import { useParticipantDrawer } from '../../hooks';
21
 
23
 
22
 import ClearableInput from './ClearableInput';
24
 import ClearableInput from './ClearableInput';
24
 import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
26
 import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
25
 import MeetingParticipantItems from './MeetingParticipantItems';
27
 import MeetingParticipantItems from './MeetingParticipantItems';
26
 
28
 
27
-type NullProto = {
28
-    [key: string]: any,
29
-    __proto__: null
30
-};
31
-
32
-type RaiseContext = NullProto | {|
33
-
34
-    /**
35
-     * Target elements against which positioning calculations are made.
36
-     */
37
-    offsetTarget?: HTMLElement,
38
-
39
-    /**
40
-     * The ID of the participant.
41
-     */
42
-    participantID ?: string,
43
-|};
44
-
45
-const initialState = Object.freeze(Object.create(null));
46
-
47
 const useStyles = makeStyles(theme => {
29
 const useStyles = makeStyles(theme => {
48
     return {
30
     return {
49
         heading: {
31
         heading: {
60
     };
42
     };
61
 });
43
 });
62
 
44
 
63
-type P = {
45
+type Props = {
46
+    currentRoom: ?Object,
64
     participantsCount: number,
47
     participantsCount: number,
65
     showInviteButton: boolean,
48
     showInviteButton: boolean,
66
     overflowDrawer: boolean,
49
     overflowDrawer: boolean,
77
  *
60
  *
78
  * @returns {ReactNode} - The component.
61
  * @returns {ReactNode} - The component.
79
  */
62
  */
80
-function MeetingParticipants({ participantsCount, showInviteButton, overflowDrawer, sortedParticipantIds = [] }: P) {
63
+function MeetingParticipants({
64
+    currentRoom,
65
+    overflowDrawer,
66
+    participantsCount,
67
+    showInviteButton,
68
+    sortedParticipantIds = []
69
+}: Props) {
81
     const dispatch = useDispatch();
70
     const dispatch = useDispatch();
82
-    const isMouseOverMenu = useRef(false);
83
-
84
-    const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
85
     const [ searchString, setSearchString ] = useState('');
71
     const [ searchString, setSearchString ] = useState('');
86
     const { t } = useTranslation();
72
     const { t } = useTranslation();
87
 
73
 
88
-    const lowerMenu = useCallback(() => {
89
-        /**
90
-         * We are tracking mouse movement over the active participant item and
91
-         * the context menu. Due to the order of enter/leave events, we need to
92
-         * defer checking if the mouse is over the context menu with
93
-         * queueMicrotask.
94
-         */
95
-        window.queueMicrotask(() => {
96
-            if (isMouseOverMenu.current) {
97
-                return;
98
-            }
99
-
100
-            if (raiseContext !== initialState) {
101
-                setRaiseContext(initialState);
102
-            }
103
-        });
104
-    }, [ raiseContext ]);
105
-
106
-    const raiseMenu = useCallback((participantID, target) => {
107
-        setRaiseContext({
108
-            participantID,
109
-            offsetTarget: findAncestorByClass(target, 'list-item-container')
110
-        });
111
-    }, [ raiseContext ]);
112
-
113
-    const toggleMenu = useCallback(participantID => e => {
114
-        const { participantID: raisedParticipant } = raiseContext;
115
-
116
-        if (raisedParticipant && raisedParticipant === participantID) {
117
-            lowerMenu();
118
-        } else {
119
-            raiseMenu(participantID, e.target);
120
-        }
121
-    }, [ raiseContext ]);
122
-
123
-    const menuEnter = useCallback(() => {
124
-        isMouseOverMenu.current = true;
125
-    }, []);
126
-
127
-    const menuLeave = useCallback(() => {
128
-        isMouseOverMenu.current = false;
129
-        lowerMenu();
130
-    }, [ lowerMenu ]);
74
+    const [ lowerMenu, , toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu();
131
 
75
 
132
     const muteAudio = useCallback(id => () => {
76
     const muteAudio = useCallback(id => () => {
133
         dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
77
         dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
136
     const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
80
     const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
137
 
81
 
138
     // FIXME:
82
     // FIXME:
139
-    // It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is
83
+    // It seems that useTranslation is not very scalable. Unmount 500 components that have the useTranslation hook is
140
     // taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
84
     // taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
141
     // solution!!!
85
     // solution!!!
142
     // One potential proper fix would be to use react-window component in order to lower the number of components
86
     // One potential proper fix would be to use react-window component in order to lower the number of components
143
     // mounted.
87
     // mounted.
144
-    const participantActionEllipsisLabel = t('MeetingParticipantItem.ParticipantActionEllipsis.options');
88
+    const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
145
     const youText = t('chat.you');
89
     const youText = t('chat.you');
146
     const askUnmuteText = t('participantsPane.actions.askUnmute');
90
     const askUnmuteText = t('participantsPane.actions.askUnmute');
147
     const muteParticipantButtonText = t('dialog.muteParticipantButton');
91
     const muteParticipantButtonText = t('dialog.muteParticipantButton');
151
     return (
95
     return (
152
         <>
96
         <>
153
             <div className = { styles.heading }>
97
             <div className = { styles.heading }>
154
-                {t('participantsPane.headings.participantsList', { count: participantsCount })}
98
+                {currentRoom?.name
99
+
100
+                    // $FlowExpectedError
101
+                    ? `${currentRoom.name} (${participantsCount})`
102
+                    : t('participantsPane.headings.participantsList', { count: participantsCount })}
155
             </div>
103
             </div>
156
             {showInviteButton && <InviteButton />}
104
             {showInviteButton && <InviteButton />}
157
             <ClearableInput
105
             <ClearableInput
168
                     participantActionEllipsisLabel = { participantActionEllipsisLabel }
116
                     participantActionEllipsisLabel = { participantActionEllipsisLabel }
169
                     participantIds = { sortedParticipantIds }
117
                     participantIds = { sortedParticipantIds }
170
                     participantsCount = { participantsCount }
118
                     participantsCount = { participantsCount }
171
-                    raiseContextId = { raiseContext.participantID }
119
+                    raiseContextId = { raiseContext.entity }
172
                     searchString = { normalizeAccents(searchString) }
120
                     searchString = { normalizeAccents(searchString) }
173
                     toggleMenu = { toggleMenu }
121
                     toggleMenu = { toggleMenu }
174
                     youText = { youText } />
122
                     youText = { youText } />
177
                 closeDrawer = { closeDrawer }
125
                 closeDrawer = { closeDrawer }
178
                 drawerParticipant = { drawerParticipant }
126
                 drawerParticipant = { drawerParticipant }
179
                 muteAudio = { muteAudio }
127
                 muteAudio = { muteAudio }
128
+                offsetTarget = { raiseContext?.offsetTarget }
180
                 onEnter = { menuEnter }
129
                 onEnter = { menuEnter }
181
                 onLeave = { menuLeave }
130
                 onLeave = { menuLeave }
182
                 onSelect = { lowerMenu }
131
                 onSelect = { lowerMenu }
183
                 overflowDrawer = { overflowDrawer }
132
                 overflowDrawer = { overflowDrawer }
184
-                { ...raiseContext } />
133
+                participantID = { raiseContext?.entity } />
185
         </>
134
         </>
186
     );
135
     );
187
 }
136
 }
205
 
154
 
206
     const overflowDrawer = showOverflowDrawer(state);
155
     const overflowDrawer = showOverflowDrawer(state);
207
 
156
 
157
+    const currentRoomId = getCurrentRoomId(state);
158
+    const currentRoom = getBreakoutRooms(state)[currentRoomId];
159
+
208
     return {
160
     return {
209
-        sortedParticipantIds,
161
+        currentRoom,
162
+        overflowDrawer,
210
         participantsCount,
163
         participantsCount,
211
         showInviteButton,
164
         showInviteButton,
212
-        overflowDrawer
165
+        sortedParticipantIds
213
     };
166
     };
214
 }
167
 }
215
 
168
 

+ 1
- 1
react/features/participants-pane/components/web/ParticipantActionEllipsis.js View File

3
 import { makeStyles } from '@material-ui/styles';
3
 import { makeStyles } from '@material-ui/styles';
4
 import React from 'react';
4
 import React from 'react';
5
 
5
 
6
-import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
6
+import { QuickActionButton } from '../../../base/components';
7
 import { Icon, IconHorizontalPoints } from '../../../base/icons';
7
 import { Icon, IconHorizontalPoints } from '../../../base/icons';
8
 
8
 
9
 type Props = {
9
 type Props = {

+ 11
- 10
react/features/participants-pane/components/web/ParticipantItem.js View File

4
 import React, { type Node, useCallback } from 'react';
4
 import React, { type Node, useCallback } from 'react';
5
 
5
 
6
 import { Avatar } from '../../../base/avatar';
6
 import { Avatar } from '../../../base/avatar';
7
-import ListItem from '../../../base/components/particpants-pane-list/ListItem';
7
+import { ListItem } from '../../../base/components';
8
 import { translate } from '../../../base/i18n';
8
 import { translate } from '../../../base/i18n';
9
 import {
9
 import {
10
     ACTION_TRIGGER,
10
     ACTION_TRIGGER,
22
     /**
22
     /**
23
      * Type of trigger for the participant actions.
23
      * Type of trigger for the participant actions.
24
      */
24
      */
25
-    actionsTrigger: ActionTrigger,
25
+    actionsTrigger?: ActionTrigger,
26
 
26
 
27
     /**
27
     /**
28
      * Media state for audio.
28
      * Media state for audio.
29
      */
29
      */
30
-    audioMediaState: MediaState,
30
+    audioMediaState?: MediaState,
31
 
31
 
32
     /**
32
     /**
33
      * React children.
33
      * React children.
34
      */
34
      */
35
-    children: Node,
35
+    children?: Node,
36
 
36
 
37
     /**
37
     /**
38
      * Whether or not to disable the moderator indicator.
38
      * Whether or not to disable the moderator indicator.
57
     /**
57
     /**
58
      * True if the participant is local.
58
      * True if the participant is local.
59
      */
59
      */
60
-    local: Boolean,
60
+    local: boolean,
61
 
61
 
62
     /**
62
     /**
63
      * Opens a drawer with participant actions.
63
      * Opens a drawer with participant actions.
64
      */
64
      */
65
-    openDrawerForParticipant: Function,
65
+    openDrawerForParticipant?: Function,
66
 
66
 
67
     /**
67
     /**
68
      * Callback for when the mouse leaves this component.
68
      * Callback for when the mouse leaves this component.
82
     /**
82
     /**
83
      * True if the participant have raised hand.
83
      * True if the participant have raised hand.
84
      */
84
      */
85
-    raisedHand: boolean,
85
+    raisedHand?: boolean,
86
 
86
 
87
     /**
87
     /**
88
      * Media state for video.
88
      * Media state for video.
89
      */
89
      */
90
-    videoMediaState: MediaState,
90
+    videoMediaState?: MediaState,
91
 
91
 
92
     /**
92
     /**
93
      * Invoked to obtain translated strings.
93
      * Invoked to obtain translated strings.
97
     /**
97
     /**
98
      * The translated "you" text.
98
      * The translated "you" text.
99
      */
99
      */
100
-    youText: string
100
+    youText?: string
101
 }
101
 }
102
 
102
 
103
 const useStyles = makeStyles(theme => {
103
 const useStyles = makeStyles(theme => {
147
     youText
147
     youText
148
 }: Props) {
148
 }: Props) {
149
     const onClick = useCallback(
149
     const onClick = useCallback(
150
-        () => openDrawerForParticipant({
150
+        () => openDrawerForParticipant && openDrawerForParticipant({
151
             participantID,
151
             participantID,
152
             displayName
152
             displayName
153
         }));
153
         }));
157
     const icon = (
157
     const icon = (
158
         <Avatar
158
         <Avatar
159
             className = 'participant-avatar'
159
             className = 'participant-avatar'
160
+            displayName = { displayName }
160
             participantId = { participantID }
161
             participantId = { participantID }
161
             size = { 32 } />
162
             size = { 32 } />
162
     );
163
     );

+ 1
- 1
react/features/participants-pane/components/web/ParticipantQuickAction.js View File

6
 import { useDispatch } from 'react-redux';
6
 import { useDispatch } from 'react-redux';
7
 
7
 
8
 import { approveParticipant } from '../../../av-moderation/actions';
8
 import { approveParticipant } from '../../../av-moderation/actions';
9
-import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
9
+import { QuickActionButton } from '../../../base/components';
10
 import { QUICK_ACTION_BUTTON } from '../../constants';
10
 import { QUICK_ACTION_BUTTON } from '../../constants';
11
 
11
 
12
 type Props = {
12
 type Props = {

+ 25
- 4
react/features/participants-pane/components/web/ParticipantsPane.js View File

9
 import { Icon, IconClose, IconHorizontalPoints } from '../../../base/icons';
9
 import { Icon, IconClose, IconHorizontalPoints } from '../../../base/icons';
10
 import { isLocalParticipantModerator } from '../../../base/participants';
10
 import { isLocalParticipantModerator } from '../../../base/participants';
11
 import { connect } from '../../../base/redux';
11
 import { connect } from '../../../base/redux';
12
+import { AddBreakoutRoomButton } from '../../../breakout-rooms/components/web/AddBreakoutRoomButton';
13
+import { RoomList } from '../../../breakout-rooms/components/web/RoomList';
12
 import { MuteEveryoneDialog } from '../../../video-menu/components/';
14
 import { MuteEveryoneDialog } from '../../../video-menu/components/';
13
 import { close } from '../../actions';
15
 import { close } from '../../actions';
14
 import { classList, findAncestorByClass, getParticipantsPaneOpen } from '../../functions';
16
 import { classList, findAncestorByClass, getParticipantsPaneOpen } from '../../functions';
23
  */
25
  */
24
 type Props = {
26
 type Props = {
25
 
27
 
28
+    /**
29
+     * Whether there is backend support for Breakout Rooms.
30
+     */
31
+    _isBreakoutRoomsSupported: Boolean,
32
+
26
     /**
33
     /**
27
      * Whether to display the context menu  as a drawer.
34
      * Whether to display the context menu  as a drawer.
28
      */
35
      */
29
     _overflowDrawer: boolean,
36
     _overflowDrawer: boolean,
30
 
37
 
38
+    /**
39
+     * Should the add breakout room button be displayed?
40
+     */
41
+    _showAddRoomButton: boolean,
42
+
31
     /**
43
     /**
32
      * Is the participants pane open.
44
      * Is the participants pane open.
33
      */
45
      */
178
      */
190
      */
179
     render() {
191
     render() {
180
         const {
192
         const {
193
+            _isBreakoutRoomsSupported,
181
             _paneOpen,
194
             _paneOpen,
195
+            _showAddRoomButton,
182
             _showFooter,
196
             _showFooter,
183
             classes,
197
             classes,
184
             t
198
             t
211
                         <LobbyParticipants />
225
                         <LobbyParticipants />
212
                         <br className = { classes.antiCollapse } />
226
                         <br className = { classes.antiCollapse } />
213
                         <MeetingParticipants />
227
                         <MeetingParticipants />
228
+                        {_isBreakoutRoomsSupported && <RoomList />}
229
+                        {_showAddRoomButton && <AddBreakoutRoomButton />}
214
                     </div>
230
                     </div>
215
                     {_showFooter && (
231
                     {_showFooter && (
216
                         <div className = { classes.footer }>
232
                         <div className = { classes.footer }>
330
  *
346
  *
331
  * @param {Object} state - The redux state.
347
  * @param {Object} state - The redux state.
332
  * @protected
348
  * @protected
333
- * @returns {{
334
- *     _paneOpen: boolean,
335
- *     _showFooter: boolean
336
- * }}
349
+ * @returns {Props}
337
  */
350
  */
338
 function _mapStateToProps(state: Object) {
351
 function _mapStateToProps(state: Object) {
339
     const isPaneOpen = getParticipantsPaneOpen(state);
352
     const isPaneOpen = getParticipantsPaneOpen(state);
353
+    const { hideAddRoomButton } = state['features/base/config'];
354
+    const { conference } = state['features/base/conference'];
355
+
356
+    // $FlowExpectedError
357
+    const _isBreakoutRoomsSupported = conference?.getBreakoutRooms()?.isSupported();
358
+    const _isLocalParticipantModerator = isLocalParticipantModerator(state);
340
 
359
 
341
     return {
360
     return {
361
+        _isBreakoutRoomsSupported,
342
         _paneOpen: isPaneOpen,
362
         _paneOpen: isPaneOpen,
363
+        _showAddRoomButton: _isBreakoutRoomsSupported && !hideAddRoomButton && _isLocalParticipantModerator,
343
         _showFooter: isPaneOpen && isLocalParticipantModerator(state)
364
         _showFooter: isPaneOpen && isLocalParticipantModerator(state)
344
     };
365
     };
345
 }
366
 }

+ 3
- 1
react/features/participants-pane/functions.js View File

17
     getRaiseHandsQueue
17
     getRaiseHandsQueue
18
 } from '../base/participants/functions';
18
 } from '../base/participants/functions';
19
 import { toState } from '../base/redux';
19
 import { toState } from '../base/redux';
20
+import { isInBreakoutRoom } from '../breakout-rooms/functions';
20
 
21
 
21
 import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
22
 import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
22
 
23
 
187
 export const shouldRenderInviteButton = (state: Object) => {
188
 export const shouldRenderInviteButton = (state: Object) => {
188
     const { disableInviteFunctions } = toState(state)['features/base/config'];
189
     const { disableInviteFunctions } = toState(state)['features/base/config'];
189
     const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
190
     const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
191
+    const inBreakoutRoom = isInBreakoutRoom(state);
190
 
192
 
191
-    return flagEnabled && !disableInviteFunctions;
193
+    return flagEnabled && !disableInviteFunctions && !inBreakoutRoom;
192
 };
194
 };
193
 
195
 
194
 /**
196
 /**

+ 41
- 5
react/features/video-menu/components/native/RemoteVideoMenu.js View File

8
 import { ColorSchemeRegistry } from '../../../base/color-scheme';
8
 import { ColorSchemeRegistry } from '../../../base/color-scheme';
9
 import { BottomSheet, isDialogOpen } from '../../../base/dialog';
9
 import { BottomSheet, isDialogOpen } from '../../../base/dialog';
10
 import { KICK_OUT_ENABLED, getFeatureFlag } from '../../../base/flags';
10
 import { KICK_OUT_ENABLED, getFeatureFlag } from '../../../base/flags';
11
+import { translate } from '../../../base/i18n';
11
 import {
12
 import {
12
     getParticipantById,
13
     getParticipantById,
13
     getParticipantDisplayName
14
     getParticipantDisplayName
14
 } from '../../../base/participants';
15
 } from '../../../base/participants';
15
 import { connect } from '../../../base/redux';
16
 import { connect } from '../../../base/redux';
16
 import { StyleType } from '../../../base/styles';
17
 import { StyleType } from '../../../base/styles';
18
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
17
 import PrivateMessageButton from '../../../chat/components/native/PrivateMessageButton';
19
 import PrivateMessageButton from '../../../chat/components/native/PrivateMessageButton';
18
 import { hideRemoteVideoMenu } from '../../actions.native';
20
 import { hideRemoteVideoMenu } from '../../actions.native';
19
 import ConnectionStatusButton from '../native/ConnectionStatusButton';
21
 import ConnectionStatusButton from '../native/ConnectionStatusButton';
25
 import MuteEveryoneElseButton from './MuteEveryoneElseButton';
27
 import MuteEveryoneElseButton from './MuteEveryoneElseButton';
26
 import MuteVideoButton from './MuteVideoButton';
28
 import MuteVideoButton from './MuteVideoButton';
27
 import PinButton from './PinButton';
29
 import PinButton from './PinButton';
30
+import SendToBreakoutRoom from './SendToBreakoutRoom';
28
 import styles from './styles';
31
 import styles from './styles';
29
 
32
 
30
 // import VolumeSlider from './VolumeSlider';
33
 // import VolumeSlider from './VolumeSlider';
52
      */
55
      */
53
     _bottomSheetStyles: StyleType,
56
     _bottomSheetStyles: StyleType,
54
 
57
 
58
+    /**
59
+     * The id of the current room.
60
+     */
61
+    _currentRoomId: String,
62
+
55
     /**
63
     /**
56
      * Whether or not to display the kick button.
64
      * Whether or not to display the kick button.
57
      */
65
      */
80
     /**
88
     /**
81
      * Display name of the participant retrieved from Redux.
89
      * Display name of the participant retrieved from Redux.
82
      */
90
      */
83
-    _participantDisplayName: string
91
+    _participantDisplayName: string,
92
+
93
+    /**
94
+     * Array containing the breakout rooms.
95
+     */
96
+    _rooms: Array<Object>,
97
+
98
+    /**
99
+     * Translation function.
100
+     */
101
+    t: Function
84
 }
102
 }
85
 
103
 
86
 // eslint-disable-next-line prefer-const
104
 // eslint-disable-next-line prefer-const
113
             _disableRemoteMute,
131
             _disableRemoteMute,
114
             _disableGrantModerator,
132
             _disableGrantModerator,
115
             _isParticipantAvailable,
133
             _isParticipantAvailable,
116
-            participantId
134
+            _rooms,
135
+            _currentRoomId,
136
+            participantId,
137
+            t
117
         } = this.props;
138
         } = this.props;
118
         const buttonProps = {
139
         const buttonProps = {
119
             afterClick: this._onCancel,
140
             afterClick: this._onCancel,
137
                 <PinButton { ...buttonProps } />
158
                 <PinButton { ...buttonProps } />
138
                 <PrivateMessageButton { ...buttonProps } />
159
                 <PrivateMessageButton { ...buttonProps } />
139
                 <ConnectionStatusButton { ...buttonProps } />
160
                 <ConnectionStatusButton { ...buttonProps } />
140
-                {/* <Divider style = { styles.divider } />*/}
161
+                {_rooms.length > 1 && <>
162
+                    <Divider style = { styles.divider } />
163
+                    <View style = { styles.contextMenuItem }>
164
+                        <Text style = { styles.contextMenuItemText }>
165
+                            {t('breakoutRooms.actions.sendToBreakoutRoom')}
166
+                        </Text>
167
+                    </View>
168
+                    {_rooms.map(room => _currentRoomId !== room.id && (<SendToBreakoutRoom
169
+                        key = { room.id }
170
+                        room = { room }
171
+                        { ...buttonProps } />))}
172
+                </>}
141
                 {/* <VolumeSlider participantID = { participantId } />*/}
173
                 {/* <VolumeSlider participantID = { participantId } />*/}
142
             </BottomSheet>
174
             </BottomSheet>
143
         );
175
         );
201
     const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config'];
233
     const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config'];
202
     const isParticipantAvailable = getParticipantById(state, participantId);
234
     const isParticipantAvailable = getParticipantById(state, participantId);
203
     let { disableKick } = remoteVideoMenu;
235
     let { disableKick } = remoteVideoMenu;
236
+    const _rooms = Object.values(getBreakoutRooms(state));
237
+    const _currentRoomId = getCurrentRoomId(state);
204
 
238
 
205
     disableKick = disableKick || !kickOutEnabled;
239
     disableKick = disableKick || !kickOutEnabled;
206
 
240
 
207
     return {
241
     return {
208
         _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'),
242
         _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'),
243
+        _currentRoomId,
209
         _disableKick: Boolean(disableKick),
244
         _disableKick: Boolean(disableKick),
210
         _disableRemoteMute: Boolean(disableRemoteMute),
245
         _disableRemoteMute: Boolean(disableRemoteMute),
211
         _isOpen: isDialogOpen(state, RemoteVideoMenu_),
246
         _isOpen: isDialogOpen(state, RemoteVideoMenu_),
212
         _isParticipantAvailable: Boolean(isParticipantAvailable),
247
         _isParticipantAvailable: Boolean(isParticipantAvailable),
213
-        _participantDisplayName: getParticipantDisplayName(state, participantId)
248
+        _participantDisplayName: getParticipantDisplayName(state, participantId),
249
+        _rooms
214
     };
250
     };
215
 }
251
 }
216
 
252
 
217
-RemoteVideoMenu_ = connect(_mapStateToProps)(RemoteVideoMenu);
253
+RemoteVideoMenu_ = translate(connect(_mapStateToProps)(RemoteVideoMenu));
218
 
254
 
219
 export default RemoteVideoMenu_;
255
 export default RemoteVideoMenu_;

+ 77
- 0
react/features/video-menu/components/native/SendToBreakoutRoom.js View File

1
+// @flow
2
+
3
+import { translate } from '../../../base/i18n';
4
+import { IconRingGroup } from '../../../base/icons';
5
+import { isLocalParticipantModerator } from '../../../base/participants';
6
+import { connect } from '../../../base/redux';
7
+import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
8
+import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
9
+
10
+export type Props = AbstractButtonProps & {
11
+
12
+    /**
13
+     * The redux {@code dispatch} function.
14
+     */
15
+    dispatch: Function,
16
+
17
+    /**
18
+     * ID of the participant to send to breakout room.
19
+     */
20
+    participantID: string,
21
+
22
+    /**
23
+     * Room to send participant to.
24
+     */
25
+    room: Object,
26
+
27
+    /**
28
+     * Translation function.
29
+     */
30
+    t: Function
31
+};
32
+
33
+/**
34
+ * An abstract remote video menu button which sends the remote participant to a breakout room.
35
+ */
36
+class SendToBreakoutRoom extends AbstractButton<Props, *> {
37
+    accessibilityLabel = 'breakoutRooms.actions.sendToBreakoutRoom';
38
+    icon = IconRingGroup;
39
+
40
+    /**
41
+     * Gets the current label.
42
+     *
43
+     * @returns {string}
44
+     */
45
+    _getLabel() {
46
+        const { t, room } = this.props;
47
+
48
+        return room.name || t('breakoutRooms.mainRoom');
49
+    }
50
+
51
+    /**
52
+     * Handles clicking / pressing the button, and asks the participant to unmute.
53
+     *
54
+     * @private
55
+     * @returns {void}
56
+     */
57
+    _handleClick() {
58
+        const { dispatch, participantID, room } = this.props;
59
+
60
+        dispatch(sendParticipantToRoom(participantID, room.id));
61
+    }
62
+}
63
+
64
+/**
65
+ * Maps part of the Redux state to the props of this component.
66
+ *
67
+ * @param {Object} state - The Redux state.
68
+ * @param {Object} ownProps - Properties of component.
69
+ * @returns {Props}
70
+ */
71
+function mapStateToProps(state) {
72
+    return {
73
+        visible: isLocalParticipantModerator(state)
74
+    };
75
+}
76
+
77
+export default translate(connect(mapStateToProps)(SendToBreakoutRoom));

+ 14
- 0
react/features/video-menu/components/native/styles.js View File

86
     toggleLabel: {
86
     toggleLabel: {
87
         marginRight: BaseTheme.spacing[3],
87
         marginRight: BaseTheme.spacing[3],
88
         maxWidth: '70%'
88
         maxWidth: '70%'
89
+    },
90
+
91
+    contextMenuItem: {
92
+        alignItems: 'center',
93
+        display: 'flex',
94
+        flexDirection: 'row',
95
+        height: BaseTheme.spacing[7],
96
+        marginLeft: BaseTheme.spacing[3]
97
+    },
98
+
99
+    contextMenuItemText: {
100
+        ...BaseTheme.typography.bodyShortRegularLarge,
101
+        color: BaseTheme.palette.text01,
102
+        marginLeft: BaseTheme.spacing[4]
89
     }
103
     }
90
 });
104
 });

+ 555
- 0
resources/prosody-plugins/mod_muc_breakout_rooms.lua View File

1
+-- This module is added under the main virtual host domain
2
+-- It needs a breakout rooms muc component
3
+--
4
+-- VirtualHost "jitmeet.example.com"
5
+--     modules_enabled = {
6
+--         "muc_breakout_rooms"
7
+--     }
8
+--     breakout_rooms_muc = "breakout.jitmeet.example.com"
9
+--     main_muc = "muc.jitmeet.example.com"
10
+--
11
+-- Component "breakout.jitmeet.example.com" "muc"
12
+--     restrict_room_creation = true
13
+--     storage = "memory"
14
+--     modules_enabled = {
15
+--         "muc_meeting_id";
16
+--         "muc_domain_mapper";
17
+--         --"token_verification";
18
+--     }
19
+--     admins = { "focusUser@auth.jitmeet.example.com" }
20
+--     muc_room_locking = false
21
+--     muc_room_default_public_jids = true
22
+--
23
+-- we use async to detect Prosody 0.10 and earlier
24
+local have_async = pcall(require, 'util.async');
25
+
26
+if not have_async then
27
+    module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.');
28
+    return;
29
+end
30
+
31
+local jid_bare = require 'util.jid'.bare;
32
+local jid_node = require 'util.jid'.node;
33
+local jid_host = require 'util.jid'.host;
34
+local jid_resource = require 'util.jid'.resource;
35
+local jid_split = require 'util.jid'.split;
36
+local json = require 'util.json';
37
+local st = require 'util.stanza';
38
+local uuid_gen = require 'util.uuid'.generate;
39
+
40
+local util = module:require 'util';
41
+local get_room_from_jid = util.get_room_from_jid;
42
+local is_healthcheck_room = util.is_healthcheck_room;
43
+
44
+local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms';
45
+-- only send at most this often updates on breakout rooms to avoid flooding.
46
+local BROADCAST_ROOMS_INTERVAL = .3;
47
+-- close conference after this amount of seconds if all leave.
48
+local ROOMS_TTL_IF_ALL_LEFT = 5;
49
+local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add';
50
+local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room';
51
+local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove';
52
+local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update';
53
+
54
+local main_muc_component_config = module:get_option_string('main_muc');
55
+if main_muc_component_config == nil then
56
+    module:log('error', 'breakout rooms not enabled missing main_muc config');
57
+    return ;
58
+end
59
+local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
60
+
61
+module:depends('jitsi_session');
62
+
63
+local breakout_rooms_muc_service;
64
+local main_muc_service;
65
+
66
+-- Maps a breakout room jid to the main room jid
67
+local main_rooms_map = {};
68
+
69
+-- Utility functions
70
+
71
+function get_main_room_jid(room_jid)
72
+    local node, host = jid_split(room_jid);
73
+
74
+	return
75
+        host == main_muc_component_config
76
+        and room_jid
77
+        or main_rooms_map[room_jid];
78
+end
79
+
80
+function get_main_room(room_jid)
81
+    local main_room_jid = get_main_room_jid(room_jid);
82
+
83
+    return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid;
84
+end
85
+
86
+function get_room_from_jid(room_jid)
87
+    local host = jid_host(room_jid);
88
+
89
+    return
90
+        host == main_muc_component_config
91
+        and main_muc_service.get_room_from_jid(room_jid)
92
+        or breakout_rooms_muc_service.get_room_from_jid(room_jid);
93
+end
94
+
95
+function send_json_msg(to_jid, json_msg)
96
+    local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; })
97
+         :tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up();
98
+    module:send(stanza);
99
+end
100
+
101
+function get_participants(room)
102
+    local participants = {};
103
+
104
+    if room then
105
+        for nick, occupant in room:each_occupant() do
106
+            -- Filter focus as we keep it as a hidden participant
107
+            if jid_node(occupant.jid) ~= 'focus' then
108
+                local display_name = occupant:get_presence():get_child_text(
109
+                    'nick', 'http://jabber.org/protocol/nick');
110
+                participants[nick] = {
111
+                    jid = occupant.jid,
112
+                    role = occupant.role,
113
+                    displayName = display_name
114
+                };
115
+            end
116
+        end
117
+    end
118
+
119
+    return participants;
120
+end
121
+
122
+function broadcast_breakout_rooms(room_jid)
123
+    local main_room, main_room_jid = get_main_room(room_jid);
124
+
125
+    if not main_room or main_room._data.is_broadcast_breakout_scheduled then
126
+        return;
127
+    end
128
+
129
+    -- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages.
130
+    main_room._data.is_broadcast_breakout_scheduled = true;
131
+    main_room:save(true);
132
+    module:add_timer(BROADCAST_ROOMS_INTERVAL, function()
133
+        main_room._data.is_broadcast_breakout_scheduled = false;
134
+        main_room:save(true);
135
+
136
+        local main_room_node = jid_node(main_room_jid)
137
+        local rooms = {
138
+            [main_room_node] = {
139
+                isMainRoom = true,
140
+                id = main_room_node,
141
+                jid = main_room_jid,
142
+                name = main_room._data.subject,
143
+                participants = get_participants(main_room)
144
+            };
145
+        }
146
+
147
+        for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do
148
+            local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
149
+            local breakout_room_node = jid_node(breakout_room_jid)
150
+
151
+            rooms[breakout_room_node] = {
152
+                id = breakout_room_node,
153
+                jid = breakout_room_jid,
154
+                name = subject,
155
+                participants = {}
156
+            }
157
+
158
+            -- The room may not physically exist yet.
159
+            if breakout_room then
160
+                rooms[breakout_room_node].participants = get_participants(breakout_room);
161
+            end
162
+        end
163
+
164
+        local json_msg = json.encode({
165
+            type = BREAKOUT_ROOMS_IDENTITY_TYPE,
166
+            event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS,
167
+            rooms = rooms
168
+        });
169
+
170
+        for _, occupant in main_room:each_occupant() do
171
+            if jid_node(occupant.jid) ~= 'focus' then
172
+                send_json_msg(occupant.jid, json_msg)
173
+            end
174
+        end
175
+
176
+        for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
177
+            local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
178
+            if room then
179
+                for _, occupant in room:each_occupant() do
180
+                    if jid_node(occupant.jid) ~= 'focus' then
181
+                        send_json_msg(occupant.jid, json_msg)
182
+                    end
183
+                end
184
+            end
185
+        end
186
+    end);
187
+end
188
+
189
+
190
+-- Managing breakout rooms
191
+
192
+function create_breakout_room(room_jid, subject)
193
+    local main_room, main_room_jid = get_main_room(room_jid);
194
+    local breakout_room_jid = uuid_gen() .. '@' .. breakout_rooms_muc_component_config;
195
+
196
+    if not main_room._data.breakout_rooms then
197
+        main_room._data.breakout_rooms = {};
198
+    end
199
+    main_room._data.breakout_rooms[breakout_room_jid] = subject;
200
+    -- Make room persistent - not to be destroyed - if all participants join breakout rooms.
201
+    main_room:set_persistent(true);
202
+    main_room:save(true);
203
+
204
+    main_rooms_map[breakout_room_jid] = main_room_jid;
205
+    broadcast_breakout_rooms(main_room_jid);
206
+end
207
+
208
+function destroy_breakout_room(room_jid, message)
209
+    local main_room, main_room_jid = get_main_room(room_jid);
210
+
211
+    if room_jid == main_room_jid then
212
+        return;
213
+    end
214
+
215
+    local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
216
+
217
+    if breakout_room then
218
+        message = message or 'Breakout room removed.';
219
+        breakout_room:destroy(main_room_jid, message);
220
+    end
221
+    if main_room then
222
+        if main_room._data.breakout_rooms then
223
+            main_room._data.breakout_rooms[room_jid] = nil;
224
+        end
225
+        main_room:save(true);
226
+
227
+        main_rooms_map[room_jid] = nil;
228
+        broadcast_breakout_rooms(main_room_jid);
229
+    end
230
+end
231
+
232
+
233
+-- Handling events
234
+
235
+function on_message(event)
236
+    local session = event.origin;
237
+
238
+    -- Check the type of the incoming stanza to avoid loops:
239
+    if event.stanza.attr.type == 'error' then
240
+        return; -- We do not want to reply to these, so leave.
241
+    end
242
+
243
+    if not session or not session.jitsi_web_query_room then
244
+        return false;
245
+    end
246
+
247
+    local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE);
248
+
249
+    if not message then
250
+        return false;
251
+    end
252
+
253
+    -- get room name with tenant and find room
254
+    local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
255
+
256
+    if not room then
257
+        module:log('warn', 'No room found found for %s/%s',
258
+                session.jitsi_web_query_prefix, session.jitsi_web_query_room);
259
+        return false;
260
+    end
261
+
262
+    -- check that the participant requesting is a moderator and is an occupant in the room
263
+    local from = event.stanza.attr.from;
264
+    local occupant = room:get_occupant_by_real_jid(from);
265
+    if not occupant then
266
+        log('warn', 'No occupant %s found for %s', from, room.jid);
267
+        return false;
268
+    end
269
+    if occupant.role ~= 'moderator' then
270
+        log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
271
+        return false;
272
+    end
273
+
274
+    if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then
275
+        create_breakout_room(room.jid, message.attr.subject);
276
+        return true;
277
+    elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then
278
+        destroy_breakout_room(message.attr.breakoutRoomJid);
279
+        return true;
280
+    elseif message.attr.type == JSON_TYPE_MOVE_TO_ROOM_REQUEST then
281
+        local participant_jid = message.attr.participantJid;
282
+        local target_room_jid = message.attr.roomJid;
283
+
284
+        local json_msg = json.encode({
285
+            type = BREAKOUT_ROOMS_IDENTITY_TYPE,
286
+            event = JSON_TYPE_MOVE_TO_ROOM_REQUEST,
287
+            roomJid = target_room_jid
288
+        });
289
+
290
+        send_json_msg(participant_jid, json_msg)
291
+        return true;
292
+    end
293
+
294
+    -- return error.
295
+    return false;
296
+end
297
+
298
+function on_breakout_room_pre_create(event)
299
+    local breakout_room = event.room;
300
+    local main_room, main_room_jid = get_main_room(breakout_room.jid);
301
+
302
+    -- Only allow existent breakout rooms to be started.
303
+    -- Authorisation of breakout rooms is done by their random uuid suffix
304
+    if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then
305
+        breakout_room._data.subject = main_room._data.breakout_rooms[breakout_room.jid];
306
+        breakout_room.save();
307
+    else
308
+        module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid);
309
+        breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
310
+        return true;
311
+    end
312
+end
313
+
314
+function on_occupant_joined(event)
315
+    local room = event.room;
316
+
317
+    if is_healthcheck_room(room.jid) then
318
+        return;
319
+    end
320
+
321
+    local main_room = get_main_room(room.jid);
322
+
323
+    if jid_node(event.occupant.jid) ~= 'focus' then
324
+        broadcast_breakout_rooms(room.jid);
325
+    end
326
+
327
+    -- Prevent closing all rooms if a participant has joined (see on_occupant_left).
328
+    if (main_room._data.is_close_all_scheduled) then
329
+        main_room._data.is_close_all_scheduled = false;
330
+        main_room:save();
331
+    end
332
+end
333
+
334
+function exist_occupants_in_room(room)
335
+    if not room then
336
+        return false;
337
+    end
338
+    for occupant_jid, occupant in room:each_occupant() do
339
+        if jid_node(occupant.jid) ~= 'focus' then
340
+            return true;
341
+        end
342
+    end
343
+
344
+    return false;
345
+end
346
+
347
+function exist_occupants_in_rooms(main_room)
348
+    if exist_occupants_in_room(main_room) then
349
+        return true;
350
+    end
351
+    for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
352
+        local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
353
+        if exist_occupants_in_room(room) then
354
+            return true;
355
+        end
356
+    end
357
+
358
+    return false;
359
+end
360
+
361
+function on_occupant_left(event)
362
+    local room = event.room;
363
+
364
+    if is_healthcheck_room(room.jid) then
365
+        return;
366
+    end
367
+
368
+    local main_room, main_room_jid = get_main_room(room.jid);
369
+
370
+    if jid_node(event.occupant.jid) ~= 'focus' then
371
+        broadcast_breakout_rooms(room.jid);
372
+    end
373
+
374
+    -- Close the conference if all left for good.
375
+    if not main_room._data.is_close_all_scheduled and not exist_occupants_in_rooms(main_room) then
376
+        main_room._data.is_close_all_scheduled = true;
377
+        main_room:save(true);
378
+        module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function()
379
+            if main_room._data.is_close_all_scheduled then
380
+                --module:log('info', 'Closing conference %s as all left for good.', main_room_jid);
381
+                main_room:set_persistent(false);
382
+                main_room:save(true);
383
+                main_room:destroy(main_room_jid, 'All occupants left.');
384
+            end
385
+        end)
386
+    end
387
+end
388
+
389
+function on_main_room_destroyed(event)
390
+    local main_room = event.room;
391
+
392
+    if is_healthcheck_room(main_room.jid) then
393
+        return;
394
+    end
395
+
396
+    local message = 'Conference ended.';
397
+
398
+    for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
399
+        destroy_breakout_room(breakout_room_jid, message)
400
+    end
401
+end
402
+
403
+
404
+-- Module operations
405
+
406
+-- process a host module directly if loaded or hooks to wait for its load
407
+function process_host_module(name, callback)
408
+    local function process_host(host)
409
+        if host == name then
410
+            callback(module:context(host), host);
411
+        end
412
+    end
413
+
414
+    if prosody.hosts[name] == nil then
415
+        module:log('debug', 'No host/component found, will wait for it: %s', name)
416
+
417
+        -- when a host or component is added
418
+        prosody.events.add_handler('host-activated', process_host);
419
+    else
420
+        process_host(name);
421
+    end
422
+end
423
+
424
+
425
+-- operates on already loaded breakout rooms muc module
426
+function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module)
427
+    module:log('debug', 'Breakout rooms muc loaded');
428
+
429
+    -- Advertise the breakout rooms component so clients can pick up the address and use it
430
+    module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config);
431
+
432
+    breakout_rooms_muc_service = breakout_rooms_muc;
433
+    module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config);
434
+    host_module:hook('message/host', on_message);
435
+    host_module:hook('muc-occupant-joined', on_occupant_joined);
436
+    host_module:hook('muc-occupant-left', on_occupant_left);
437
+    host_module:hook('muc-room-pre-create', on_breakout_room_pre_create);
438
+
439
+    host_module:hook('muc-disco#info', function (event)
440
+        local room = event.room;
441
+        local main_room, main_room_jid = get_main_room(room.jid);
442
+
443
+        -- Breakout room matadata.
444
+        table.insert(event.form, {
445
+            name = 'muc#roominfo_isbreakout';
446
+            label = 'Is this a breakout room?';
447
+            type = "boolean";
448
+        });
449
+        event.formdata['muc#roominfo_isbreakout'] = true;
450
+        table.insert(event.form, {
451
+            name = 'muc#roominfo_breakout_main_room';
452
+            label = 'The main room associated with this breakout room';
453
+        });
454
+        event.formdata['muc#roominfo_breakout_main_room'] = main_room_jid;
455
+
456
+        -- If the main room has a lobby, make it so this breakout room also uses it.
457
+        if (main_room._data.lobbyroom and main_room:get_members_only()) then
458
+            table.insert(event.form, {
459
+                name = 'muc#roominfo_lobbyroom';
460
+                label = 'Lobby room jid';
461
+            });
462
+            event.formdata['muc#roominfo_lobbyroom'] = main_room._data.lobbyroom;
463
+        end
464
+    end);
465
+
466
+    host_module:hook("muc-config-form", function(event)
467
+        local room = event.room;
468
+        local main_room, main_room_jid = get_main_room(room.jid);
469
+
470
+        -- Breakout room matadata.
471
+        table.insert(event.form, {
472
+            name = 'muc#roominfo_isbreakout';
473
+            label = 'Is this a breakout room?';
474
+            type = "boolean";
475
+            value = true;
476
+        });
477
+
478
+        table.insert(event.form, {
479
+            name = 'muc#roominfo_breakout_main_room';
480
+            label = 'The main room associated with this breakout room';
481
+            value = main_room_jid;
482
+        });
483
+    end);
484
+
485
+    local room_mt = breakout_rooms_muc_service.room_mt;
486
+
487
+    room_mt.get_members_only = function(room)
488
+        local main_room = get_main_room(room.jid);
489
+
490
+        return main_room.get_members_only(main_room)
491
+    end
492
+
493
+    -- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc
494
+    room_mt.get_affiliation = function(room, jid)
495
+        local main_room, main_room_jid = get_main_room(room.jid);
496
+
497
+        if not main_room then
498
+            module:log('error', 'No main room(%s) for %s!', room.jid, jid);
499
+            return 'none';
500
+        end
501
+
502
+        -- moderators in main room are moderators here
503
+        local role = main_room.get_affiliation(main_room, jid);
504
+        if role then
505
+            return role;
506
+        end
507
+
508
+        return 'none';
509
+    end
510
+end
511
+
512
+-- process or waits to process the breakout rooms muc component
513
+process_host_module(breakout_rooms_muc_component_config, function(host_module, host)
514
+    module:log('info', 'Breakout rooms component created %s', host);
515
+
516
+    local muc_module = prosody.hosts[host].modules.muc;
517
+
518
+    if muc_module then
519
+        process_breakout_rooms_muc_loaded(muc_module, host_module);
520
+    else
521
+        module:log('debug', 'Will wait for muc to be available');
522
+        prosody.hosts[host].events.add_handler('module-loaded', function(event)
523
+            if (event.module == 'muc') then
524
+                process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module);
525
+            end
526
+        end);
527
+    end
528
+end);
529
+
530
+-- operates on already loaded main muc module
531
+function process_main_muc_loaded(main_muc, host_module)
532
+    module:log('debug', 'Main muc loaded');
533
+
534
+    main_muc_service = main_muc;
535
+    module:log("info", "Hook to muc events on %s", main_muc_component_config);
536
+    host_module:hook('muc-occupant-joined', on_occupant_joined);
537
+    host_module:hook('muc-occupant-left', on_occupant_left);
538
+    host_module:hook('muc-room-destroyed', on_main_room_destroyed);
539
+end
540
+
541
+-- process or waits to process the main muc component
542
+process_host_module(main_muc_component_config, function(host_module, host)
543
+    local muc_module = prosody.hosts[host].modules.muc;
544
+
545
+    if muc_module then
546
+        process_main_muc_loaded(muc_module, host_module);
547
+    else
548
+        module:log('debug', 'Will wait for muc to be available');
549
+        prosody.hosts[host].events.add_handler('module-loaded', function(event)
550
+            if (event.module == 'muc') then
551
+                process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
552
+            end
553
+        end);
554
+    end
555
+end);

+ 13
- 1
resources/prosody-plugins/mod_muc_lobby_rooms.lua View File

113
 
113
 
114
     if from_domain == lobby_muc_component_config then
114
     if from_domain == lobby_muc_component_config then
115
         if stanza.name == 'presence' then
115
         if stanza.name == 'presence' then
116
-            if presence_check_status(stanza:get_child('x', MUC_NS..'#user'), '110') then
116
+            local muc_x = stanza:get_child('x', MUC_NS..'#user');
117
+            if not muc_x or presence_check_status(muc_x, '110') then
117
                 return stanza;
118
                 return stanza;
118
             end
119
             end
119
 
120
 
124
                 return stanza;
125
                 return stanza;
125
             end
126
             end
126
 
127
 
128
+            -- check is an owner, only owners can receive the presence
129
+            -- do not forward presence of owners (other than unavailable)
130
+            local room = main_muc_service.get_room_from_jid(jid_bare(node .. '@' .. main_muc_component_config));
131
+            local item = muc_x:get_child('item');
132
+            if not room
133
+                or stanza.attr.type == 'unavailable'
134
+                or (room.get_affiliation(room, stanza.attr.to) == 'owner'
135
+                    and room.get_affiliation(room, item.attr.jid) ~= 'owner') then
136
+                return stanza;
137
+            end
138
+
127
             local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
139
             local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
128
             local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
140
             local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
129
             if not from_occupant then
141
             if not from_occupant then

Loading…
Cancel
Save