Selaa lähdekoodia

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 vuotta sitten
vanhempi
commit
b5faf9f62a
60 muutettua tiedostoa jossa 2483 lisäystä ja 132 poistoa
  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 Näytä tiedosto

@@ -29,6 +29,7 @@ import { shouldShowModeratedNotification } from './react/features/av-moderation/
29 29
 import {
30 30
     AVATAR_URL_COMMAND,
31 31
     EMAIL_COMMAND,
32
+    _conferenceWillJoin,
32 33
     authStatusChanged,
33 34
     commonUserJoinedHandling,
34 35
     commonUserLeftHandling,
@@ -47,7 +48,7 @@ import {
47 48
     onStartMutedPolicyChanged,
48 49
     p2pStatusChanged,
49 50
     sendLocalParticipant,
50
-    _conferenceWillJoin
51
+    nonParticipantMessageReceived
51 52
 } from './react/features/base/conference';
52 53
 import { getReplaceParticipant } from './react/features/base/config/functions';
53 54
 import {
@@ -1360,11 +1361,49 @@ export default {
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 1400
         room
1365 1401
             = connection.initJitsiConference(
1366 1402
                 APP.conference.roomName,
1367
-                this._getConferenceOptions());
1403
+                {
1404
+                    ...this._getConferenceOptions(),
1405
+                    ...extraOptions
1406
+                });
1368 1407
 
1369 1408
         // Filter out the tracks that are muted (except on Safari).
1370 1409
         const tracks = browser.isWebKitBased() ? localTracks : localTracks.filter(track => !track.isMuted());
@@ -2222,6 +2261,10 @@ export default {
2222 2261
                 }
2223 2262
             });
2224 2263
 
2264
+        room.on(
2265
+            JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
2266
+            (...args) => APP.store.dispatch(nonParticipantMessageReceived(...args)));
2267
+
2225 2268
         room.on(
2226 2269
             JitsiConferenceEvents.LOCK_STATE_CHANGED,
2227 2270
             (...args) => APP.store.dispatch(lockStateChanged(room, ...args)));
@@ -2904,6 +2947,17 @@ export default {
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 2962
      * Leaves the room and calls JitsiConnection.disconnect.
2909 2963
      *

+ 4
- 1
config.js Näytä tiedosto

@@ -431,7 +431,10 @@ var config = {
431 431
     // hideLobbyButton: false,
432 432
 
433 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 439
     // Require users to always specify a display name.
437 440
     // requireDisplayName: true,

+ 14
- 0
doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example Näytä tiedosto

@@ -52,10 +52,12 @@ VirtualHost "jitmeet.example.com"
52 52
         "external_services";
53 53
         "conference_duration";
54 54
         "muc_lobby_rooms";
55
+        "muc_breakout_rooms";
55 56
         "av_moderation";
56 57
     }
57 58
     c2s_require_encryption = false
58 59
     lobby_muc = "lobby.jitmeet.example.com"
60
+    breakout_rooms_muc = "breakout.jitmeet.example.com"
59 61
     main_muc = "conference.jitmeet.example.com"
60 62
     -- muc_lobby_whitelist = { "recorder.jitmeet.example.com" } -- Here we can whitelist jibri to enter lobby enabled rooms
61 63
 
@@ -73,6 +75,18 @@ Component "conference.jitmeet.example.com" "muc"
73 75
     muc_room_locking = false
74 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 90
 -- internal muc component
77 91
 Component "internal.auth.jitmeet.example.com" "muc"
78 92
     storage = "memory"

+ 18
- 0
lang/main.json Näytä tiedosto

@@ -39,6 +39,20 @@
39 39
     "audioOnly": {
40 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 56
     "calendarSync": {
43 57
         "addMeetingURL": "Add a meeting link",
44 58
         "confirmAddLink": "Do you want to add a Jitsi link to this event?",
@@ -623,6 +637,7 @@
623 637
             "invite": "Invite Someone",
624 638
             "askUnmute": "Ask to unmute",
625 639
             "moreModerationActions": "More moderation options",
640
+            "moreParticipantOptions": "More participant options",
626 641
             "mute": "Mute",
627 642
             "muteAll": "Mute all",
628 643
             "muteEveryoneElse": "Mute everyone else",
@@ -886,6 +901,7 @@
886 901
             "audioOnly": "Toggle audio only",
887 902
             "audioRoute": "Select the sound device",
888 903
             "boo": "Boo",
904
+            "breakoutRoom": "Join/leave breakout room",
889 905
             "callQuality": "Manage video quality",
890 906
             "cc": "Toggle subtitles",
891 907
             "chat": "Open / Close chat",
@@ -969,7 +985,9 @@
969 985
         "hangup": "Leave the meeting",
970 986
         "help": "Help",
971 987
         "invite": "Invite people",
988
+        "joinBreakoutRoom": "Join breakout room",
972 989
         "laugh": "Laugh",
990
+        "leaveBreakoutRoom": "Leave breakout room",
973 991
         "like": "Thumbs Up",
974 992
         "lobbyButtonDisable": "Disable lobby mode",
975 993
         "lobbyButtonEnable": "Enable lobby mode",

+ 1
- 0
react/features/app/middlewares.any.js Näytä tiedosto

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

+ 1
- 0
react/features/app/reducers.any.js Näytä tiedosto

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

+ 79
- 0
react/features/base/components/context-menu/useContextMenu.js Näytä tiedosto

@@ -0,0 +1,79 @@
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 Näytä tiedosto

@@ -0,0 +1,4 @@
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 Näytä tiedosto

@@ -1,9 +1,11 @@
1 1
 // @flow
2 2
 
3 3
 import { makeStyles } from '@material-ui/styles';
4
+import clsx from 'clsx';
4 5
 import React from 'react';
5 6
 
6 7
 import { ACTION_TRIGGER } from '../../../participants-pane/constants';
8
+import { isMobileBrowser } from '../../environment/utils';
7 9
 import participantsPaneTheme from '../themes/participantsPaneTheme.json';
8 10
 
9 11
 type Props = {
@@ -13,6 +15,11 @@ type Props = {
13 15
      */
14 16
     actions: React$Node,
15 17
 
18
+    /**
19
+     * List item container class name.
20
+     */
21
+    className: string,
22
+
16 23
     /**
17 24
      * Icon to be displayed on the list item. (Avatar for participants).
18 25
      */
@@ -43,11 +50,21 @@ type Props = {
43 50
      */
44 51
     onClick: Function,
45 52
 
53
+    /**
54
+     * Long press handler.
55
+     */
56
+    onLongPress: Function,
57
+
46 58
     /**
47 59
      * Mouse leave handler.
48 60
      */
49 61
     onMouseLeave: Function,
50 62
 
63
+    /**
64
+     * Data test id.
65
+     */
66
+    testId?: string,
67
+
51 68
     /**
52 69
      * Text children to be displayed on the list item.
53 70
      */
@@ -72,6 +89,7 @@ const useStyles = makeStyles(theme => {
72 89
             padding: `0 ${participantsPaneTheme.panePadding}px`,
73 90
             position: 'relative',
74 91
             boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
92
+            minHeight: '40px',
75 93
 
76 94
             '&:hover': {
77 95
                 backgroundColor: theme.palette.action02Active,
@@ -161,24 +179,75 @@ const useStyles = makeStyles(theme => {
161 179
 
162 180
 const ListItem = ({
163 181
     actions,
182
+    className,
164 183
     icon,
165 184
     id,
166 185
     hideActions = false,
167 186
     indicators,
168 187
     isHighlighted,
169 188
     onClick,
189
+    onLongPress,
170 190
     onMouseLeave,
191
+    testId,
171 192
     textChildren,
172 193
     trigger
173 194
 }: Props) => {
174 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 231
     return (
177 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 239
             id = { id }
180 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 251
             <div> {icon} </div>
183 252
             <div className = { styles.detailsContainer }>
184 253
                 <div className = { styles.name }>
@@ -186,17 +255,20 @@ const ListItem = ({
186 255
                 </div>
187 256
                 {indicators && (
188 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 262
                         {indicators}
193 263
                     </div>
194 264
                 )}
195 265
                 {!hideActions && (
196 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 272
                         {actions}
201 273
                     </div>
202 274
                 )}

+ 11
- 0
react/features/base/conference/actionTypes.js Näytä tiedosto

@@ -127,6 +127,17 @@ export const KICKED_OUT = 'KICKED_OUT';
127 127
  */
128 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 142
  * The type of (redux) action which sets the peer2peer flag for the current
132 143
  * conference.

+ 41
- 2
react/features/base/conference/actions.js Näytä tiedosto

@@ -37,6 +37,7 @@ import {
37 37
     DATA_CHANNEL_OPENED,
38 38
     KICKED_OUT,
39 39
     LOCK_STATE_CHANGED,
40
+    NON_PARTICIPANT_MESSAGE_RECEIVED,
40 41
     P2P_STATUS_CHANGED,
41 42
     SEND_TONES,
42 43
     SET_FOLLOW_ME,
@@ -179,6 +180,10 @@ function _addConferenceListeners(conference, dispatch, state) {
179 180
         JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
180 181
         (...args) => dispatch(endpointMessageReceived(...args)));
181 182
 
183
+    conference.on(
184
+        JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
185
+        (...args) => dispatch(nonParticipantMessageReceived(...args)));
186
+
182 187
     conference.on(
183 188
         JitsiConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED,
184 189
         (...args) => dispatch(participantConnectionStatusChanged(...args)));
@@ -415,9 +420,11 @@ export function conferenceWillLeave(conference: Object) {
415 420
 /**
416 421
  * Initializes a new conference.
417 422
  *
423
+ * @param {string} overrideRoom - Override the room to join, instead of taking it
424
+ * from Redux.
418 425
  * @returns {Function}
419 426
  */
420
-export function createConference() {
427
+export function createConference(overrideRoom?: string) {
421 428
     return (dispatch: Function, getState: Function) => {
422 429
         const state = getState();
423 430
         const { connection, locationURL } = state['features/base/connection'];
@@ -432,7 +439,20 @@ export function createConference() {
432 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 457
         connection[JITSI_CONNECTION_CONFERENCE_KEY] = conference;
438 458
 
@@ -525,6 +545,25 @@ export function lockStateChanged(conference: Object, locked: boolean) {
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 568
  * Updates the known state of start muted policies.
530 569
  *

+ 1
- 0
react/features/base/config/configWhitelist.js Näytä tiedosto

@@ -158,6 +158,7 @@ export default [
158 158
     'hideParticipantsStats',
159 159
     'hideConferenceTimer',
160 160
     'hiddenDomain',
161
+    'hideAddRoomButton',
161 162
     'hideLobbyButton',
162 163
     'hosts',
163 164
     'iAmRecorder',

+ 3
- 0
react/features/base/icons/svg/icon-ring-group.svg Näytä tiedosto

@@ -0,0 +1,3 @@
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 Näytä tiedosto

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

+ 7
- 0
react/features/breakout-rooms/actionTypes.js Näytä tiedosto

@@ -0,0 +1,7 @@
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 Näytä tiedosto

@@ -0,0 +1,236 @@
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 Näytä tiedosto

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

+ 3
- 0
react/features/breakout-rooms/components/_.web.js Näytä tiedosto

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

+ 3
- 0
react/features/breakout-rooms/components/index.js Näytä tiedosto

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

+ 31
- 0
react/features/breakout-rooms/components/native/AddBreakoutRoomButton.js Näytä tiedosto

@@ -0,0 +1,31 @@
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 Näytä tiedosto

@@ -0,0 +1,31 @@
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 Näytä tiedosto

@@ -0,0 +1,85 @@
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 Näytä tiedosto

@@ -0,0 +1,25 @@
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 Näytä tiedosto

@@ -0,0 +1,72 @@
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 Näytä tiedosto

@@ -0,0 +1,31 @@
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 Näytä tiedosto

@@ -0,0 +1,5 @@
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 Näytä tiedosto

@@ -0,0 +1,69 @@
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 Näytä tiedosto

@@ -0,0 +1,36 @@
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 Näytä tiedosto

@@ -0,0 +1,42 @@
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 Näytä tiedosto

@@ -0,0 +1,127 @@
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 Näytä tiedosto

@@ -0,0 +1,47 @@
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 Näytä tiedosto

@@ -0,0 +1,42 @@
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 Näytä tiedosto

@@ -0,0 +1,40 @@
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 Näytä tiedosto

@@ -0,0 +1,100 @@
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 Näytä tiedosto

@@ -0,0 +1,63 @@
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 Näytä tiedosto

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

+ 22
- 0
react/features/breakout-rooms/constants.js Näytä tiedosto

@@ -0,0 +1,22 @@
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 Näytä tiedosto

@@ -0,0 +1,59 @@
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 Näytä tiedosto

@@ -0,0 +1,7 @@
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 Näytä tiedosto

@@ -0,0 +1,31 @@
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 Näytä tiedosto

@@ -0,0 +1,25 @@
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 Näytä tiedosto

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

+ 15
- 2
react/features/participants-pane/components/native/MeetingParticipantList.js Näytä tiedosto

@@ -9,6 +9,7 @@ import { Icon, IconInviteMore } from '../../../base/icons';
9 9
 import { getLocalParticipant, getParticipantCountWithFake, getRemoteParticipants } from '../../../base/participants';
10 10
 import { connect } from '../../../base/redux';
11 11
 import { normalizeAccents } from '../../../base/util/strings';
12
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
12 13
 import { doInvitePeople } from '../../../invite/actions.native';
13 14
 import { shouldRenderInviteButton } from '../../functions';
14 15
 
@@ -19,6 +20,11 @@ import styles from './styles';
19 20
 
20 21
 type Props = {
21 22
 
23
+    /**
24
+     * Current breakout room, if we are in one.
25
+     */
26
+    _currentRoom: ?Object,
27
+
22 28
     /**
23 29
      * The local participant.
24 30
      */
@@ -186,6 +192,7 @@ class MeetingParticipantList extends PureComponent<Props, State> {
186 192
      */
187 193
     render() {
188 194
         const {
195
+            _currentRoom,
189 196
             _localParticipant,
190 197
             _participantsCount,
191 198
             _showInviteButton,
@@ -197,8 +204,11 @@ class MeetingParticipantList extends PureComponent<Props, State> {
197 204
             <View
198 205
                 style = { styles.meetingListContainer }>
199 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 212
                 </Text>
203 213
                 {
204 214
                     _showInviteButton
@@ -241,8 +251,11 @@ function _mapStateToProps(state): Object {
241 251
     const { remoteParticipants } = state['features/filmstrip'];
242 252
     const _showInviteButton = shouldRenderInviteButton(state);
243 253
     const _remoteParticipants = getRemoteParticipants(state);
254
+    const currentRoomId = getCurrentRoomId(state);
255
+    const _currentRoom = getBreakoutRooms(state)[currentRoomId];
244 256
 
245 257
     return {
258
+        _currentRoom,
246 259
         _participantsCount,
247 260
         _remoteParticipants,
248 261
         _showInviteButton,

+ 5
- 4
react/features/participants-pane/components/native/ParticipantItem.js Näytä tiedosto

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

+ 33
- 0
react/features/participants-pane/components/native/ParticipantsPane.js Näytä tiedosto

@@ -9,8 +9,17 @@ import { useDispatch, useSelector } from 'react-redux';
9 9
 import { openDialog } from '../../../base/dialog';
10 10
 import JitsiScreen from '../../../base/modal/components/JitsiScreen';
11 11
 import {
12
+    getParticipantCount,
12 13
     isLocalParticipantModerator
13 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 23
 import MuteEveryoneDialog
15 24
     from '../../../video-menu/components/native/MuteEveryoneDialog';
16 25
 
@@ -33,12 +42,36 @@ const ParticipantsPane = () => {
33 42
         [ dispatch ]);
34 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 57
     return (
37 58
         <JitsiScreen
38 59
             style = { styles.participantsPane }>
39 60
             <ScrollView bounces = { false }>
40 61
                 <LobbyParticipantList />
41 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 75
             </ScrollView>
43 76
             {
44 77
                 isLocalModerator

+ 1
- 2
react/features/participants-pane/components/web/FooterContextMenu.js Näytä tiedosto

@@ -15,8 +15,7 @@ import {
15 15
     isEnabled as isAvModerationEnabled,
16 16
     isSupported as isAvModerationSupported
17 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 19
 import { openDialog } from '../../../base/dialog';
21 20
 import { IconCheck, IconVideoOff } from '../../../base/icons';
22 21
 import { MEDIA_TYPE } from '../../../base/media';

+ 1
- 1
react/features/participants-pane/components/web/LobbyParticipantQuickAction.js Näytä tiedosto

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

+ 79
- 5
react/features/participants-pane/components/web/MeetingParticipantContextMenu.js Näytä tiedosto

@@ -1,10 +1,10 @@
1 1
 // @flow
2
+import { withStyles } from '@material-ui/styles';
2 3
 import React, { Component } from 'react';
3 4
 
4 5
 import { approveParticipant } from '../../../av-moderation/actions';
5 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 8
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
9 9
 import { openDialog } from '../../../base/dialog';
10 10
 import { isIosMobileBrowser } from '../../../base/environment/utils';
@@ -16,6 +16,7 @@ import {
16 16
     IconMicDisabled,
17 17
     IconMicrophone,
18 18
     IconMuteEveryoneElse,
19
+    IconRingGroup,
19 20
     IconShareVideo,
20 21
     IconVideoOff
21 22
 } from '../../../base/icons';
@@ -28,6 +29,8 @@ import {
28 29
 } from '../../../base/participants';
29 30
 import { connect } from '../../../base/redux';
30 31
 import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
32
+import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
33
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
31 34
 import { openChatById } from '../../../chat/actions';
32 35
 import { setVolume } from '../../../filmstrip/actions.web';
33 36
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
@@ -42,6 +45,11 @@ type Props = {
42 45
      */
43 46
     _isAudioForceMuted: boolean,
44 47
 
48
+    /**
49
+     * The id of the current room.
50
+     */
51
+    _currentRoomId: String,
52
+
45 53
     /**
46 54
      * True if the local participant is moderator and false otherwise.
47 55
      */
@@ -82,6 +90,11 @@ type Props = {
82 90
      */
83 91
     _participant: Object,
84 92
 
93
+    /**
94
+     * Rooms reference.
95
+     */
96
+    _rooms: Array<Object>,
97
+
85 98
     /**
86 99
      * A value between 0 and 1 indicating the volume of the participant's
87 100
      * audio element.
@@ -117,7 +130,7 @@ type Props = {
117 130
     /**
118 131
      * Target elements against which positioning calculations are made.
119 132
      */
120
-    offsetTarget: HTMLElement,
133
+    offsetTarget?: HTMLElement,
121 134
 
122 135
     /**
123 136
      * Callback for the mouse entering the component.
@@ -137,7 +150,7 @@ type Props = {
137 150
     /**
138 151
      * The ID of the participant.
139 152
      */
140
-    participantID: string,
153
+    participantID?: string,
141 154
 
142 155
     /**
143 156
      * True if an overflow drawer should be displayed.
@@ -150,6 +163,20 @@ type Props = {
150 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 181
  * Implements the MeetingParticipantContextMenu component.
155 182
  */
@@ -170,6 +197,7 @@ class MeetingParticipantContextMenu extends Component<Props> {
170 197
         this._onMuteVideo = this._onMuteVideo.bind(this);
171 198
         this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
172 199
         this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
200
+        this._onSendToRoom = this._onSendToRoom.bind(this);
173 201
         this._onVolumeChange = this._onVolumeChange.bind(this);
174 202
         this._onAskToUnmute = this._onAskToUnmute.bind(this);
175 203
     }
@@ -265,6 +293,22 @@ class MeetingParticipantContextMenu extends Component<Props> {
265 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 312
     _onVolumeChange: (number) => void;
269 313
 
270 314
     /**
@@ -304,6 +348,7 @@ class MeetingParticipantContextMenu extends Component<Props> {
304 348
     render() {
305 349
         const {
306 350
             _isAudioForceMuted,
351
+            _currentRoomId,
307 352
             _isLocalModerator,
308 353
             _isChatButtonEnabled,
309 354
             _isParticipantModerator,
@@ -312,7 +357,9 @@ class MeetingParticipantContextMenu extends Component<Props> {
312 357
             _isVideoForceMuted,
313 358
             _localVideoOwner,
314 359
             _participant,
360
+            _rooms,
315 361
             _volume = 1,
362
+            classes,
316 363
             closeDrawer,
317 364
             drawerParticipant,
318 365
             offsetTarget,
@@ -391,6 +438,20 @@ class MeetingParticipantContextMenu extends Component<Props> {
391 438
             } : null
392 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 455
         const actions
395 456
             = _participant?.isFakeParticipant ? (
396 457
                 <>
@@ -406,6 +467,15 @@ class MeetingParticipantContextMenu extends Component<Props> {
406 467
                     }
407 468
 
408 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 479
                     { showVolumeSlider
410 480
                         && <ContextMenuItemGroup>
411 481
                             <VolumeSlider
@@ -456,11 +526,13 @@ function _mapStateToProps(state, ownProps): Object {
456 526
     const participant = getParticipantByIdOrUndefined(state,
457 527
         overflowDrawer ? drawerParticipant?.participantID : participantID);
458 528
 
529
+    const _currentRoomId = getCurrentRoomId(state);
459 530
     const _isLocalModerator = isLocalParticipantModerator(state);
460 531
     const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
461 532
     const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
462 533
     const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
463 534
     const _isParticipantModerator = isParticipantModerator(participant);
535
+    const _rooms = Object.values(getBreakoutRooms(state));
464 536
 
465 537
     const { participantsVolume } = state['features/filmstrip'];
466 538
     const id = participant?.id;
@@ -468,6 +540,7 @@ function _mapStateToProps(state, ownProps): Object {
468 540
 
469 541
     return {
470 542
         _isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
543
+        _currentRoomId,
471 544
         _isLocalModerator,
472 545
         _isChatButtonEnabled,
473 546
         _isParticipantModerator,
@@ -476,8 +549,9 @@ function _mapStateToProps(state, ownProps): Object {
476 549
         _isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
477 550
         _localVideoOwner: Boolean(ownerId === localParticipantId),
478 551
         _participant: participant,
552
+        _rooms,
479 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 Näytä tiedosto

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

+ 30
- 77
react/features/participants-pane/components/web/MeetingParticipants.js Näytä tiedosto

@@ -1,11 +1,12 @@
1 1
 // @flow
2 2
 
3 3
 import { makeStyles } from '@material-ui/styles';
4
-import React, { useCallback, useRef, useState } from 'react';
4
+import React, { useCallback, useState } from 'react';
5 5
 import { useTranslation } from 'react-i18next';
6 6
 import { useDispatch } from 'react-redux';
7 7
 
8 8
 import { rejectParticipantAudio } from '../../../av-moderation/actions';
9
+import useContextMenu from '../../../base/components/context-menu/useContextMenu';
9 10
 import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
10 11
 import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
11 12
 import { MEDIA_TYPE } from '../../../base/media';
@@ -14,9 +15,10 @@ import {
14 15
 } from '../../../base/participants';
15 16
 import { connect } from '../../../base/redux';
16 17
 import { normalizeAccents } from '../../../base/util/strings';
18
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
17 19
 import { showOverflowDrawer } from '../../../toolbox/functions';
18 20
 import { muteRemote } from '../../../video-menu/actions.any';
19
-import { findAncestorByClass, getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
21
+import { getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
20 22
 import { useParticipantDrawer } from '../../hooks';
21 23
 
22 24
 import ClearableInput from './ClearableInput';
@@ -24,26 +26,6 @@ import { InviteButton } from './InviteButton';
24 26
 import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
25 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 29
 const useStyles = makeStyles(theme => {
48 30
     return {
49 31
         heading: {
@@ -60,7 +42,8 @@ const useStyles = makeStyles(theme => {
60 42
     };
61 43
 });
62 44
 
63
-type P = {
45
+type Props = {
46
+    currentRoom: ?Object,
64 47
     participantsCount: number,
65 48
     showInviteButton: boolean,
66 49
     overflowDrawer: boolean,
@@ -77,57 +60,18 @@ type P = {
77 60
  *
78 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 70
     const dispatch = useDispatch();
82
-    const isMouseOverMenu = useRef(false);
83
-
84
-    const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
85 71
     const [ searchString, setSearchString ] = useState('');
86 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 76
     const muteAudio = useCallback(id => () => {
133 77
         dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
@@ -136,12 +80,12 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
136 80
     const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
137 81
 
138 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 84
     // taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
141 85
     // solution!!!
142 86
     // One potential proper fix would be to use react-window component in order to lower the number of components
143 87
     // mounted.
144
-    const participantActionEllipsisLabel = t('MeetingParticipantItem.ParticipantActionEllipsis.options');
88
+    const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
145 89
     const youText = t('chat.you');
146 90
     const askUnmuteText = t('participantsPane.actions.askUnmute');
147 91
     const muteParticipantButtonText = t('dialog.muteParticipantButton');
@@ -151,7 +95,11 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
151 95
     return (
152 96
         <>
153 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 103
             </div>
156 104
             {showInviteButton && <InviteButton />}
157 105
             <ClearableInput
@@ -168,7 +116,7 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
168 116
                     participantActionEllipsisLabel = { participantActionEllipsisLabel }
169 117
                     participantIds = { sortedParticipantIds }
170 118
                     participantsCount = { participantsCount }
171
-                    raiseContextId = { raiseContext.participantID }
119
+                    raiseContextId = { raiseContext.entity }
172 120
                     searchString = { normalizeAccents(searchString) }
173 121
                     toggleMenu = { toggleMenu }
174 122
                     youText = { youText } />
@@ -177,11 +125,12 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
177 125
                 closeDrawer = { closeDrawer }
178 126
                 drawerParticipant = { drawerParticipant }
179 127
                 muteAudio = { muteAudio }
128
+                offsetTarget = { raiseContext?.offsetTarget }
180 129
                 onEnter = { menuEnter }
181 130
                 onLeave = { menuLeave }
182 131
                 onSelect = { lowerMenu }
183 132
                 overflowDrawer = { overflowDrawer }
184
-                { ...raiseContext } />
133
+                participantID = { raiseContext?.entity } />
185 134
         </>
186 135
     );
187 136
 }
@@ -205,11 +154,15 @@ function _mapStateToProps(state): Object {
205 154
 
206 155
     const overflowDrawer = showOverflowDrawer(state);
207 156
 
157
+    const currentRoomId = getCurrentRoomId(state);
158
+    const currentRoom = getBreakoutRooms(state)[currentRoomId];
159
+
208 160
     return {
209
-        sortedParticipantIds,
161
+        currentRoom,
162
+        overflowDrawer,
210 163
         participantsCount,
211 164
         showInviteButton,
212
-        overflowDrawer
165
+        sortedParticipantIds
213 166
     };
214 167
 }
215 168
 

+ 1
- 1
react/features/participants-pane/components/web/ParticipantActionEllipsis.js Näytä tiedosto

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

+ 11
- 10
react/features/participants-pane/components/web/ParticipantItem.js Näytä tiedosto

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

+ 1
- 1
react/features/participants-pane/components/web/ParticipantQuickAction.js Näytä tiedosto

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

+ 25
- 4
react/features/participants-pane/components/web/ParticipantsPane.js Näytä tiedosto

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

+ 3
- 1
react/features/participants-pane/functions.js Näytä tiedosto

@@ -17,6 +17,7 @@ import {
17 17
     getRaiseHandsQueue
18 18
 } from '../base/participants/functions';
19 19
 import { toState } from '../base/redux';
20
+import { isInBreakoutRoom } from '../breakout-rooms/functions';
20 21
 
21 22
 import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
22 23
 
@@ -187,8 +188,9 @@ export function getQuickActionButtonType(participant: Object, isAudioMuted: Bool
187 188
 export const shouldRenderInviteButton = (state: Object) => {
188 189
     const { disableInviteFunctions } = toState(state)['features/base/config'];
189 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 Näytä tiedosto

@@ -8,12 +8,14 @@ import { Avatar } from '../../../base/avatar';
8 8
 import { ColorSchemeRegistry } from '../../../base/color-scheme';
9 9
 import { BottomSheet, isDialogOpen } from '../../../base/dialog';
10 10
 import { KICK_OUT_ENABLED, getFeatureFlag } from '../../../base/flags';
11
+import { translate } from '../../../base/i18n';
11 12
 import {
12 13
     getParticipantById,
13 14
     getParticipantDisplayName
14 15
 } from '../../../base/participants';
15 16
 import { connect } from '../../../base/redux';
16 17
 import { StyleType } from '../../../base/styles';
18
+import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
17 19
 import PrivateMessageButton from '../../../chat/components/native/PrivateMessageButton';
18 20
 import { hideRemoteVideoMenu } from '../../actions.native';
19 21
 import ConnectionStatusButton from '../native/ConnectionStatusButton';
@@ -25,6 +27,7 @@ import MuteButton from './MuteButton';
25 27
 import MuteEveryoneElseButton from './MuteEveryoneElseButton';
26 28
 import MuteVideoButton from './MuteVideoButton';
27 29
 import PinButton from './PinButton';
30
+import SendToBreakoutRoom from './SendToBreakoutRoom';
28 31
 import styles from './styles';
29 32
 
30 33
 // import VolumeSlider from './VolumeSlider';
@@ -52,6 +55,11 @@ type Props = {
52 55
      */
53 56
     _bottomSheetStyles: StyleType,
54 57
 
58
+    /**
59
+     * The id of the current room.
60
+     */
61
+    _currentRoomId: String,
62
+
55 63
     /**
56 64
      * Whether or not to display the kick button.
57 65
      */
@@ -80,7 +88,17 @@ type Props = {
80 88
     /**
81 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 104
 // eslint-disable-next-line prefer-const
@@ -113,7 +131,10 @@ class RemoteVideoMenu extends PureComponent<Props> {
113 131
             _disableRemoteMute,
114 132
             _disableGrantModerator,
115 133
             _isParticipantAvailable,
116
-            participantId
134
+            _rooms,
135
+            _currentRoomId,
136
+            participantId,
137
+            t
117 138
         } = this.props;
118 139
         const buttonProps = {
119 140
             afterClick: this._onCancel,
@@ -137,7 +158,18 @@ class RemoteVideoMenu extends PureComponent<Props> {
137 158
                 <PinButton { ...buttonProps } />
138 159
                 <PrivateMessageButton { ...buttonProps } />
139 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 173
                 {/* <VolumeSlider participantID = { participantId } />*/}
142 174
             </BottomSheet>
143 175
         );
@@ -201,19 +233,23 @@ function _mapStateToProps(state, ownProps) {
201 233
     const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config'];
202 234
     const isParticipantAvailable = getParticipantById(state, participantId);
203 235
     let { disableKick } = remoteVideoMenu;
236
+    const _rooms = Object.values(getBreakoutRooms(state));
237
+    const _currentRoomId = getCurrentRoomId(state);
204 238
 
205 239
     disableKick = disableKick || !kickOutEnabled;
206 240
 
207 241
     return {
208 242
         _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'),
243
+        _currentRoomId,
209 244
         _disableKick: Boolean(disableKick),
210 245
         _disableRemoteMute: Boolean(disableRemoteMute),
211 246
         _isOpen: isDialogOpen(state, RemoteVideoMenu_),
212 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 255
 export default RemoteVideoMenu_;

+ 77
- 0
react/features/video-menu/components/native/SendToBreakoutRoom.js Näytä tiedosto

@@ -0,0 +1,77 @@
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 Näytä tiedosto

@@ -86,5 +86,19 @@ export default createStyleSheet({
86 86
     toggleLabel: {
87 87
         marginRight: BaseTheme.spacing[3],
88 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 Näytä tiedosto

@@ -0,0 +1,555 @@
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 Näytä tiedosto

@@ -113,7 +113,8 @@ function filter_stanza(stanza)
113 113
 
114 114
     if from_domain == lobby_muc_component_config then
115 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 118
                 return stanza;
118 119
             end
119 120
 
@@ -124,6 +125,17 @@ function filter_stanza(stanza)
124 125
                 return stanza;
125 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 139
             local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
128 140
             local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
129 141
             if not from_occupant then

Loading…
Peruuta
Tallenna