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>
This commit is contained in:
parent
d98ea3ca24
commit
b5faf9f62a
|
@ -29,6 +29,7 @@ import { shouldShowModeratedNotification } from './react/features/av-moderation/
|
|||
import {
|
||||
AVATAR_URL_COMMAND,
|
||||
EMAIL_COMMAND,
|
||||
_conferenceWillJoin,
|
||||
authStatusChanged,
|
||||
commonUserJoinedHandling,
|
||||
commonUserLeftHandling,
|
||||
|
@ -47,7 +48,7 @@ import {
|
|||
onStartMutedPolicyChanged,
|
||||
p2pStatusChanged,
|
||||
sendLocalParticipant,
|
||||
_conferenceWillJoin
|
||||
nonParticipantMessageReceived
|
||||
} from './react/features/base/conference';
|
||||
import { getReplaceParticipant } from './react/features/base/config/functions';
|
||||
import {
|
||||
|
@ -1360,11 +1361,49 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
_createRoom(localTracks) {
|
||||
/**
|
||||
* Used by the Breakout Rooms feature to join a breakout room or go back to the main room.
|
||||
*/
|
||||
async joinRoom(roomName, isBreakoutRoom = false) {
|
||||
this.roomName = roomName;
|
||||
|
||||
const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks();
|
||||
const localTracks = await tryCreateLocalTracks;
|
||||
|
||||
this._displayErrorsForCreateInitialLocalTracks(errors);
|
||||
localTracks.forEach(track => {
|
||||
if ((track.isAudioTrack() && this.isLocalAudioMuted())
|
||||
|| (track.isVideoTrack() && this.isLocalVideoMuted())) {
|
||||
track.mute();
|
||||
}
|
||||
});
|
||||
this._createRoom(localTracks, isBreakoutRoom);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
new ConferenceConnector(resolve, reject).connect();
|
||||
});
|
||||
},
|
||||
|
||||
_createRoom(localTracks, isBreakoutRoom = false) {
|
||||
const extraOptions = {};
|
||||
|
||||
if (isBreakoutRoom) {
|
||||
// We must be in a room already.
|
||||
if (!room?.xmpp?.breakoutRoomsComponentAddress) {
|
||||
throw new Error('Breakout Rooms not enabled');
|
||||
}
|
||||
|
||||
// TODO: re-evaluate this. -saghul
|
||||
extraOptions.customDomain = room.xmpp.breakoutRoomsComponentAddress;
|
||||
}
|
||||
|
||||
room
|
||||
= connection.initJitsiConference(
|
||||
APP.conference.roomName,
|
||||
this._getConferenceOptions());
|
||||
{
|
||||
...this._getConferenceOptions(),
|
||||
...extraOptions
|
||||
});
|
||||
|
||||
// Filter out the tracks that are muted (except on Safari).
|
||||
const tracks = browser.isWebKitBased() ? localTracks : localTracks.filter(track => !track.isMuted());
|
||||
|
@ -2222,6 +2261,10 @@ export default {
|
|||
}
|
||||
});
|
||||
|
||||
room.on(
|
||||
JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
(...args) => APP.store.dispatch(nonParticipantMessageReceived(...args)));
|
||||
|
||||
room.on(
|
||||
JitsiConferenceEvents.LOCK_STATE_CHANGED,
|
||||
(...args) => APP.store.dispatch(lockStateChanged(room, ...args)));
|
||||
|
@ -2904,6 +2947,17 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Leaves the room.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
leaveRoom() {
|
||||
if (room && room.isJoined()) {
|
||||
return room.leave();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leaves the room and calls JitsiConnection.disconnect.
|
||||
*
|
||||
|
|
|
@ -431,7 +431,10 @@ var config = {
|
|||
// hideLobbyButton: false,
|
||||
|
||||
// If Lobby is enabled starts knocking automatically.
|
||||
// autoKnockLobby: false
|
||||
// autoKnockLobby: false,
|
||||
|
||||
// Hides add breakout room button
|
||||
// hideAddRoomButton: false,
|
||||
|
||||
// Require users to always specify a display name.
|
||||
// requireDisplayName: true,
|
||||
|
|
|
@ -52,10 +52,12 @@ VirtualHost "jitmeet.example.com"
|
|||
"external_services";
|
||||
"conference_duration";
|
||||
"muc_lobby_rooms";
|
||||
"muc_breakout_rooms";
|
||||
"av_moderation";
|
||||
}
|
||||
c2s_require_encryption = false
|
||||
lobby_muc = "lobby.jitmeet.example.com"
|
||||
breakout_rooms_muc = "breakout.jitmeet.example.com"
|
||||
main_muc = "conference.jitmeet.example.com"
|
||||
-- muc_lobby_whitelist = { "recorder.jitmeet.example.com" } -- Here we can whitelist jibri to enter lobby enabled rooms
|
||||
|
||||
|
@ -73,6 +75,18 @@ Component "conference.jitmeet.example.com" "muc"
|
|||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
Component "breakout.jitmeet.example.com" "muc"
|
||||
restrict_room_creation = true
|
||||
storage = "memory"
|
||||
modules_enabled = {
|
||||
"muc_meeting_id";
|
||||
"muc_domain_mapper";
|
||||
--"token_verification";
|
||||
}
|
||||
admins = { "focusUser@auth.jitmeet.example.com" }
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
-- internal muc component
|
||||
Component "internal.auth.jitmeet.example.com" "muc"
|
||||
storage = "memory"
|
||||
|
|
|
@ -39,6 +39,20 @@
|
|||
"audioOnly": {
|
||||
"audioOnly": "Low bandwidth"
|
||||
},
|
||||
"breakoutRooms": {
|
||||
"defaultName": "Breakout room #{{index}}",
|
||||
"mainRoom": "Main room",
|
||||
"actions": {
|
||||
"add": "Add breakout room",
|
||||
"autoAssign": "Auto assign to breakout rooms",
|
||||
"close": "Close",
|
||||
"join": "Join",
|
||||
"leaveBreakoutRoom": "Leave breakout room",
|
||||
"more": "More",
|
||||
"remove": "Remove",
|
||||
"sendToBreakoutRoom": "Send participant to:"
|
||||
}
|
||||
},
|
||||
"calendarSync": {
|
||||
"addMeetingURL": "Add a meeting link",
|
||||
"confirmAddLink": "Do you want to add a Jitsi link to this event?",
|
||||
|
@ -623,6 +637,7 @@
|
|||
"invite": "Invite Someone",
|
||||
"askUnmute": "Ask to unmute",
|
||||
"moreModerationActions": "More moderation options",
|
||||
"moreParticipantOptions": "More participant options",
|
||||
"mute": "Mute",
|
||||
"muteAll": "Mute all",
|
||||
"muteEveryoneElse": "Mute everyone else",
|
||||
|
@ -886,6 +901,7 @@
|
|||
"audioOnly": "Toggle audio only",
|
||||
"audioRoute": "Select the sound device",
|
||||
"boo": "Boo",
|
||||
"breakoutRoom": "Join/leave breakout room",
|
||||
"callQuality": "Manage video quality",
|
||||
"cc": "Toggle subtitles",
|
||||
"chat": "Open / Close chat",
|
||||
|
@ -969,7 +985,9 @@
|
|||
"hangup": "Leave the meeting",
|
||||
"help": "Help",
|
||||
"invite": "Invite people",
|
||||
"joinBreakoutRoom": "Join breakout room",
|
||||
"laugh": "Laugh",
|
||||
"leaveBreakoutRoom": "Leave breakout room",
|
||||
"like": "Thumbs Up",
|
||||
"lobbyButtonDisable": "Disable lobby mode",
|
||||
"lobbyButtonEnable": "Enable lobby mode",
|
||||
|
|
|
@ -19,6 +19,7 @@ import '../base/sounds/middleware';
|
|||
import '../base/testing/middleware';
|
||||
import '../base/tracks/middleware';
|
||||
import '../base/user-interaction/middleware';
|
||||
import '../breakout-rooms/middleware';
|
||||
import '../calendar-sync/middleware';
|
||||
import '../chat/middleware';
|
||||
import '../conference/middleware';
|
||||
|
|
|
@ -26,6 +26,7 @@ import '../base/sounds/reducer';
|
|||
import '../base/testing/reducer';
|
||||
import '../base/tracks/reducer';
|
||||
import '../base/user-interaction/reducer';
|
||||
import '../breakout-rooms/reducer';
|
||||
import '../calendar-sync/reducer';
|
||||
import '../chat/reducer';
|
||||
import '../deep-linking/reducer';
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
// @flow
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { findAncestorByClass } from '../../../participants-pane/functions';
|
||||
|
||||
type NullProto = {
|
||||
[key: string]: any,
|
||||
__proto__: null
|
||||
};
|
||||
|
||||
type RaiseContext = NullProto | {|
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget?: HTMLElement,
|
||||
|
||||
/**
|
||||
* The entity for which the menu is context menu is raised.
|
||||
*/
|
||||
entity?: string | Object,
|
||||
|};
|
||||
|
||||
const initialState = Object.freeze(Object.create(null));
|
||||
|
||||
const useContextMenu = () => {
|
||||
const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
|
||||
const isMouseOverMenu = useRef(false);
|
||||
|
||||
const lowerMenu = useCallback((force: boolean | Object = false) => {
|
||||
/**
|
||||
* We are tracking mouse movement over the active participant item and
|
||||
* the context menu. Due to the order of enter/leave events, we need to
|
||||
* defer checking if the mouse is over the context menu with
|
||||
* queueMicrotask.
|
||||
*/
|
||||
window.queueMicrotask(() => {
|
||||
if (isMouseOverMenu.current && !(force === true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (raiseContext !== initialState) {
|
||||
setRaiseContext(initialState);
|
||||
}
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const raiseMenu = useCallback((entity: string | Object, target: EventTarget) => {
|
||||
setRaiseContext({
|
||||
entity,
|
||||
offsetTarget: findAncestorByClass(target, 'list-item-container')
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const toggleMenu = useCallback((entity: string | Object) => (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const { entity: raisedEntity } = raiseContext;
|
||||
|
||||
if (raisedEntity && raisedEntity === entity) {
|
||||
lowerMenu();
|
||||
} else {
|
||||
raiseMenu(entity, e.target);
|
||||
}
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const menuEnter = useCallback(() => {
|
||||
isMouseOverMenu.current = true;
|
||||
}, []);
|
||||
|
||||
const menuLeave = useCallback(() => {
|
||||
isMouseOverMenu.current = false;
|
||||
lowerMenu();
|
||||
}, [ lowerMenu ]);
|
||||
|
||||
return [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ];
|
||||
};
|
||||
|
||||
export default useContextMenu;
|
|
@ -0,0 +1,4 @@
|
|||
export { default as ContextMenu } from './context-menu/ContextMenu';
|
||||
export { default as ContextMenuItemGroup } from './context-menu/ContextMenuItemGroup';
|
||||
export { default as ListItem } from './participants-pane-list/ListItem';
|
||||
export { default as QuickActionButton } from './buttons/QuickActionButton';
|
|
@ -1,9 +1,11 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
import { ACTION_TRIGGER } from '../../../participants-pane/constants';
|
||||
import { isMobileBrowser } from '../../environment/utils';
|
||||
import participantsPaneTheme from '../themes/participantsPaneTheme.json';
|
||||
|
||||
type Props = {
|
||||
|
@ -13,6 +15,11 @@ type Props = {
|
|||
*/
|
||||
actions: React$Node,
|
||||
|
||||
/**
|
||||
* List item container class name.
|
||||
*/
|
||||
className: string,
|
||||
|
||||
/**
|
||||
* Icon to be displayed on the list item. (Avatar for participants).
|
||||
*/
|
||||
|
@ -43,11 +50,21 @@ type Props = {
|
|||
*/
|
||||
onClick: Function,
|
||||
|
||||
/**
|
||||
* Long press handler.
|
||||
*/
|
||||
onLongPress: Function,
|
||||
|
||||
/**
|
||||
* Mouse leave handler.
|
||||
*/
|
||||
onMouseLeave: Function,
|
||||
|
||||
/**
|
||||
* Data test id.
|
||||
*/
|
||||
testId?: string,
|
||||
|
||||
/**
|
||||
* Text children to be displayed on the list item.
|
||||
*/
|
||||
|
@ -72,6 +89,7 @@ const useStyles = makeStyles(theme => {
|
|||
padding: `0 ${participantsPaneTheme.panePadding}px`,
|
||||
position: 'relative',
|
||||
boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
|
||||
minHeight: '40px',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action02Active,
|
||||
|
@ -161,24 +179,75 @@ const useStyles = makeStyles(theme => {
|
|||
|
||||
const ListItem = ({
|
||||
actions,
|
||||
className,
|
||||
icon,
|
||||
id,
|
||||
hideActions = false,
|
||||
indicators,
|
||||
isHighlighted,
|
||||
onClick,
|
||||
onLongPress,
|
||||
onMouseLeave,
|
||||
testId,
|
||||
textChildren,
|
||||
trigger
|
||||
}: Props) => {
|
||||
const styles = useStyles();
|
||||
const _isMobile = isMobileBrowser();
|
||||
let timeoutHandler;
|
||||
|
||||
/**
|
||||
* Set calling long press handler after x milliseconds.
|
||||
*
|
||||
* @param {TouchEvent} e - Touch start event.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchStart(e) {
|
||||
const target = e.touches[0].target;
|
||||
|
||||
timeoutHandler = setTimeout(() => onLongPress(target), 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel calling on long press after x milliseconds if the number of milliseconds is not reached
|
||||
* before a touch move(drag), or just clears the timeout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchMove() {
|
||||
clearTimeout(timeoutHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel calling on long press after x milliseconds if the number of milliseconds is not reached yet,
|
||||
* or just clears the timeout.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTouchEnd() {
|
||||
clearTimeout(timeoutHandler);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { `list-item-container ${styles.container} ${isHighlighted ? styles.highlighted : ''}` }
|
||||
className = { clsx('list-item-container',
|
||||
styles.container,
|
||||
isHighlighted && styles.highlighted,
|
||||
className
|
||||
) }
|
||||
data-testid = { testId }
|
||||
id = { id }
|
||||
onClick = { onClick }
|
||||
onMouseLeave = { onMouseLeave }>
|
||||
{ ...(_isMobile
|
||||
? {
|
||||
onTouchEnd: _onTouchEnd,
|
||||
onTouchMove: _onTouchMove,
|
||||
onTouchStart: _onTouchStart
|
||||
}
|
||||
: {
|
||||
onMouseLeave
|
||||
}
|
||||
) }>
|
||||
<div> {icon} </div>
|
||||
<div className = { styles.detailsContainer }>
|
||||
<div className = { styles.name }>
|
||||
|
@ -186,17 +255,20 @@ const ListItem = ({
|
|||
</div>
|
||||
{indicators && (
|
||||
<div
|
||||
className = { `indicators ${styles.indicators} ${
|
||||
isHighlighted || trigger === ACTION_TRIGGER.PERMANENT
|
||||
? styles.indicatorsHidden : ''}` }>
|
||||
className = { clsx('indicators',
|
||||
styles.indicators,
|
||||
(isHighlighted || trigger === ACTION_TRIGGER.PERMANENT) && styles.indicatorsHidden
|
||||
) }>
|
||||
{indicators}
|
||||
</div>
|
||||
)}
|
||||
{!hideActions && (
|
||||
<div
|
||||
className = { `actions ${styles.actionsContainer} ${
|
||||
trigger === ACTION_TRIGGER.PERMANENT ? styles.actionsPermanent : ''} ${
|
||||
isHighlighted ? styles.actionsVisible : ''}` }>
|
||||
className = { clsx('actions',
|
||||
styles.actionsContainer,
|
||||
trigger === ACTION_TRIGGER.PERMANENT && styles.actionsPermanent,
|
||||
isHighlighted && styles.actionsVisible
|
||||
) }>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
|
@ -127,6 +127,17 @@ export const KICKED_OUT = 'KICKED_OUT';
|
|||
*/
|
||||
export const LOCK_STATE_CHANGED = 'LOCK_STATE_CHANGED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that a system (non-participant) message has been received.
|
||||
*
|
||||
* {
|
||||
* type: NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
* id: String,
|
||||
* json: Object
|
||||
* }
|
||||
*/
|
||||
export const NON_PARTICIPANT_MESSAGE_RECEIVED = 'NON_PARTICIPANT_MESSAGE_RECEIVED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the peer2peer flag for the current
|
||||
* conference.
|
||||
|
|
|
@ -37,6 +37,7 @@ import {
|
|||
DATA_CHANNEL_OPENED,
|
||||
KICKED_OUT,
|
||||
LOCK_STATE_CHANGED,
|
||||
NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
P2P_STATUS_CHANGED,
|
||||
SEND_TONES,
|
||||
SET_FOLLOW_ME,
|
||||
|
@ -179,6 +180,10 @@ function _addConferenceListeners(conference, dispatch, state) {
|
|||
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
|
||||
(...args) => dispatch(endpointMessageReceived(...args)));
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
(...args) => dispatch(nonParticipantMessageReceived(...args)));
|
||||
|
||||
conference.on(
|
||||
JitsiConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED,
|
||||
(...args) => dispatch(participantConnectionStatusChanged(...args)));
|
||||
|
@ -415,9 +420,11 @@ export function conferenceWillLeave(conference: Object) {
|
|||
/**
|
||||
* Initializes a new conference.
|
||||
*
|
||||
* @param {string} overrideRoom - Override the room to join, instead of taking it
|
||||
* from Redux.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function createConference() {
|
||||
export function createConference(overrideRoom?: string) {
|
||||
return (dispatch: Function, getState: Function) => {
|
||||
const state = getState();
|
||||
const { connection, locationURL } = state['features/base/connection'];
|
||||
|
@ -432,7 +439,20 @@ export function createConference() {
|
|||
throw new Error('Cannot join a conference without a room name!');
|
||||
}
|
||||
|
||||
const conference = connection.initJitsiConference(getBackendSafeRoomName(room), getConferenceOptions(state));
|
||||
// XXX: revisit this.
|
||||
// Hide the custom domain in the room name.
|
||||
const tmp = overrideRoom || room;
|
||||
let _room = getBackendSafeRoomName(tmp);
|
||||
|
||||
if (tmp.domain) {
|
||||
// eslint-disable-next-line no-new-wrappers
|
||||
_room = new String(tmp);
|
||||
|
||||
// $FlowExpectedError
|
||||
_room.domain = tmp.domain;
|
||||
}
|
||||
|
||||
const conference = connection.initJitsiConference(_room, getConferenceOptions(state));
|
||||
|
||||
connection[JITSI_CONNECTION_CONFERENCE_KEY] = conference;
|
||||
|
||||
|
@ -525,6 +545,25 @@ export function lockStateChanged(conference: Object, locked: boolean) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that a non participant endpoint message has been received.
|
||||
*
|
||||
* @param {string} id - The resource id of the sender.
|
||||
* @param {Object} json - The json carried by the endpoint message.
|
||||
* @returns {{
|
||||
* type: NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
* id: Object,
|
||||
* json: Object
|
||||
* }}
|
||||
*/
|
||||
export function nonParticipantMessageReceived(id: String, json: Object) {
|
||||
return {
|
||||
type: NON_PARTICIPANT_MESSAGE_RECEIVED,
|
||||
id,
|
||||
json
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the known state of start muted policies.
|
||||
*
|
||||
|
|
|
@ -158,6 +158,7 @@ export default [
|
|||
'hideParticipantsStats',
|
||||
'hideConferenceTimer',
|
||||
'hiddenDomain',
|
||||
'hideAddRoomButton',
|
||||
'hideLobbyButton',
|
||||
'hosts',
|
||||
'iAmRecorder',
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="15" viewBox="0 0 16 15" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -101,6 +101,7 @@ export { default as IconRemoteControlStart } from './play.svg';
|
|||
export { default as IconRemoteControlStop } from './stop.svg';
|
||||
export { default as IconReply } from './reply.svg';
|
||||
export { default as IconRestore } from './restore.svg';
|
||||
export { default as IconRingGroup } from './icon-ring-group.svg';
|
||||
export { default as IconRoomLock } from './security.svg';
|
||||
export { default as IconRoomUnlock } from './security-locked.svg';
|
||||
export { default as IconSecurityOff } from './security-off.svg';
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
// @flow
|
||||
|
||||
/**
|
||||
* The type of (redux) action to update the breakout room data.
|
||||
*
|
||||
*/
|
||||
export const UPDATE_BREAKOUT_ROOMS = 'UPDATE_BREAKOUT_ROOMS';
|
|
@ -0,0 +1,236 @@
|
|||
// @flow
|
||||
|
||||
import i18next from 'i18next';
|
||||
import _ from 'lodash';
|
||||
import type { Dispatch } from 'redux';
|
||||
|
||||
import {
|
||||
conferenceLeft,
|
||||
conferenceWillLeave,
|
||||
createConference,
|
||||
getCurrentConference
|
||||
} from '../base/conference';
|
||||
import { setAudioMuted, setVideoMuted } from '../base/media';
|
||||
import { getRemoteParticipants } from '../base/participants';
|
||||
import { clearNotifications } from '../notifications';
|
||||
|
||||
import {
|
||||
getBreakoutRooms,
|
||||
getMainRoom
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
declare var APP: Object;
|
||||
|
||||
/**
|
||||
* Action to create a breakout room.
|
||||
*
|
||||
* @param {string} name - Name / subject for the breakout room.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function createBreakoutRoom(name?: string) {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
const rooms = getBreakoutRooms(getState);
|
||||
|
||||
// TODO: remove this once we add UI to customize the name.
|
||||
const index = Object.keys(rooms).length;
|
||||
const subject = name || i18next.t('breakoutRooms.defaultName', { index });
|
||||
|
||||
// $FlowExpectedError
|
||||
getCurrentConference(getState)?.getBreakoutRooms()
|
||||
?.createBreakoutRoom(subject);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to close a room and send participants to the main room.
|
||||
*
|
||||
* @param {string} roomId - The id of the room to close.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function closeBreakoutRoom(roomId: string) {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
const rooms = getBreakoutRooms(getState);
|
||||
const room = rooms[roomId];
|
||||
const mainRoom = getMainRoom(getState);
|
||||
|
||||
if (room && mainRoom) {
|
||||
Object.values(room.participants).forEach(p => {
|
||||
|
||||
// $FlowExpectedError
|
||||
dispatch(sendParticipantToRoom(p.jid, mainRoom.id));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to remove a breakout room.
|
||||
*
|
||||
* @param {string} breakoutRoomJid - The jid of the breakout room to remove.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function removeBreakoutRoom(breakoutRoomJid: string) {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
// $FlowExpectedError
|
||||
getCurrentConference(getState)?.getBreakoutRooms()
|
||||
?.removeBreakoutRoom(breakoutRoomJid);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to auto-assign the participants to breakout rooms.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function autoAssignToBreakoutRooms() {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
const rooms = getBreakoutRooms(getState);
|
||||
const breakoutRooms = _.filter(rooms, (room: Object) => !room.isMainRoom);
|
||||
|
||||
if (breakoutRooms) {
|
||||
const participantIds = Array.from(getRemoteParticipants(getState).keys());
|
||||
const length = Math.ceil(participantIds.length / breakoutRooms.length);
|
||||
|
||||
_.chunk(_.shuffle(participantIds), length).forEach((group, index) =>
|
||||
group.forEach(participantId => {
|
||||
dispatch(sendParticipantToRoom(participantId, breakoutRooms[index].id));
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to send a participant to a room.
|
||||
*
|
||||
* @param {string} participantId - The participant id.
|
||||
* @param {string} roomId - The room id.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function sendParticipantToRoom(participantId: string, roomId: string) {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
const rooms = getBreakoutRooms(getState);
|
||||
const room = rooms[roomId];
|
||||
|
||||
if (!room) {
|
||||
logger.warn(`Invalid room: ${roomId}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the full JID of the participant. We could be getting the endpoint ID or
|
||||
// a participant JID. We want to find the connection JID.
|
||||
const participantJid = _findParticipantJid(getState, participantId);
|
||||
|
||||
if (!participantJid) {
|
||||
logger.warn(`Could not find participant ${participantId}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// $FlowExpectedError
|
||||
getCurrentConference(getState)?.getBreakoutRooms()
|
||||
?.sendParticipantToRoom(participantJid, room.jid);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to move to a room.
|
||||
*
|
||||
* @param {string} roomId - The room id to move to. If omitted move to the main room.
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function moveToRoom(roomId?: string) {
|
||||
return (dispatch: Dispatch<any>, getState: Function) => {
|
||||
let _roomId = roomId || getMainRoom(getState)?.id;
|
||||
|
||||
// Check if we got a full JID.
|
||||
// $FlowExpectedError
|
||||
if (_roomId?.indexOf('@') !== -1) {
|
||||
// $FlowExpectedError
|
||||
const [ id, ...domainParts ] = _roomId.split('@');
|
||||
|
||||
// On mobile we first store the room and the connection is created
|
||||
// later, so let's attach the domain to the room String object as
|
||||
// a little hack.
|
||||
|
||||
// eslint-disable-next-line no-new-wrappers
|
||||
_roomId = new String(id);
|
||||
|
||||
// $FlowExpectedError
|
||||
_roomId.domain = domainParts.join('@');
|
||||
}
|
||||
|
||||
if (navigator.product === 'ReactNative') {
|
||||
const conference = getCurrentConference(getState);
|
||||
const { audio, video } = getState()['features/base/media'];
|
||||
|
||||
dispatch(conferenceWillLeave(conference));
|
||||
conference.leave()
|
||||
.catch(error => {
|
||||
logger.warn(
|
||||
'JitsiConference.leave() rejected with:',
|
||||
error);
|
||||
|
||||
dispatch(conferenceLeft(conference));
|
||||
});
|
||||
dispatch(clearNotifications());
|
||||
|
||||
// dispatch(setRoom(_roomId));
|
||||
dispatch(createConference(_roomId));
|
||||
dispatch(setAudioMuted(audio.muted));
|
||||
dispatch(setVideoMuted(video.muted));
|
||||
} else {
|
||||
APP.conference.leaveRoom()
|
||||
.finally(() => APP.conference.joinRoom(_roomId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a participant's connection JID given its ID.
|
||||
*
|
||||
* @param {Function} getState - The redux store state getter.
|
||||
* @param {string} participantId - ID of the given participant.
|
||||
* @returns {string|undefined} - The participant connection JID if found.
|
||||
*/
|
||||
function _findParticipantJid(getState: Function, participantId: string) {
|
||||
const conference = getCurrentConference(getState);
|
||||
|
||||
if (!conference) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the full JID of the participant. We could be getting the endpoint ID or
|
||||
// a participant JID. We want to find the connection JID.
|
||||
let _participantId = participantId;
|
||||
let participantJid;
|
||||
|
||||
if (!participantId.includes('@')) {
|
||||
const p = conference.getParticipantById(participantId);
|
||||
|
||||
// $FlowExpectedError
|
||||
_participantId = p?.getJid(); // This will be the room JID.
|
||||
}
|
||||
|
||||
if (_participantId) {
|
||||
const rooms = getBreakoutRooms(getState);
|
||||
|
||||
for (const room of Object.values(rooms)) {
|
||||
// $FlowExpectedError
|
||||
const participants = room.participants || {};
|
||||
const p = participants[_participantId]
|
||||
|
||||
// $FlowExpectedError
|
||||
|| Object.values(participants).find(item => item.jid === _participantId);
|
||||
|
||||
if (p) {
|
||||
participantJid = p.jid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return participantJid;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
// @flow
|
||||
|
||||
export * from './native';
|
|
@ -0,0 +1,3 @@
|
|||
// @flow
|
||||
|
||||
export * from './web';
|
|
@ -0,0 +1,3 @@
|
|||
// @flow
|
||||
|
||||
export * from './_';
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { createBreakoutRoom } from '../../actions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const AddBreakoutRoomButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onAdd = useCallback(() =>
|
||||
dispatch(createBreakoutRoom())
|
||||
, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { t('breakoutRooms.actions.add') }
|
||||
children = { t('breakoutRooms.actions.add') }
|
||||
labelStyle = { styles.addButtonLabel }
|
||||
mode = 'contained'
|
||||
onPress = { onAdd }
|
||||
style = { styles.addButton } />
|
||||
);
|
||||
};
|
||||
|
||||
export default AddBreakoutRoomButton;
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { autoAssignToBreakoutRooms } from '../../actions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const AutoAssignButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onAutoAssign = useCallback(() => {
|
||||
dispatch(autoAssignToBreakoutRooms());
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { t('breakoutRooms.actions.autoAssign') }
|
||||
children = { t('breakoutRooms.actions.autoAssign') }
|
||||
labelStyle = { styles.autoAssignLabel }
|
||||
mode = 'contained'
|
||||
onPress = { onAutoAssign }
|
||||
style = { styles.transparentButton } />
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoAssignButton;
|
|
@ -0,0 +1,85 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { hideDialog } from '../../../base/dialog/actions';
|
||||
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
|
||||
import {
|
||||
Icon,
|
||||
IconClose,
|
||||
IconRingGroup
|
||||
} from '../../../base/icons';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import styles from '../../../participants-pane/components/native/styles';
|
||||
import { closeBreakoutRoom, moveToRoom, removeBreakoutRoom } from '../../actions';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The room for which the menu is open.
|
||||
*/
|
||||
room: Object
|
||||
}
|
||||
|
||||
const BreakoutRoomContextMenu = ({ room }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const closeDialog = useCallback(() => dispatch(hideDialog()), [ dispatch ]);
|
||||
const isLocalModerator = useSelector(isLocalParticipantModerator);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onJoinRoom = useCallback(() => {
|
||||
dispatch(moveToRoom(room.jid));
|
||||
closeDialog();
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
const onRemoveBreakoutRoom = useCallback(() => {
|
||||
dispatch(removeBreakoutRoom(room.jid));
|
||||
closeDialog();
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
const onCloseBreakoutRoom = useCallback(() => {
|
||||
dispatch(closeBreakoutRoom(room.id));
|
||||
closeDialog();
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
addScrollViewPadding = { false }
|
||||
onCancel = { closeDialog }
|
||||
showSlidingView = { true }>
|
||||
<TouchableOpacity
|
||||
onPress = { onJoinRoom }
|
||||
style = { styles.contextMenuItem }>
|
||||
<Icon
|
||||
size = { 24 }
|
||||
src = { IconRingGroup } />
|
||||
<Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.join')}</Text>
|
||||
</TouchableOpacity>
|
||||
{!room?.isMainRoom && isLocalModerator
|
||||
&& !(room?.participants && Object.keys(room.participants).length > 0)
|
||||
? <TouchableOpacity
|
||||
onPress = { onRemoveBreakoutRoom }
|
||||
style = { styles.contextMenuItem }>
|
||||
<Icon
|
||||
size = { 24 }
|
||||
src = { IconClose } />
|
||||
<Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.remove')}</Text>
|
||||
</TouchableOpacity>
|
||||
: <TouchableOpacity
|
||||
onPress = { onCloseBreakoutRoom }
|
||||
style = { styles.contextMenuItem }>
|
||||
<Icon
|
||||
size = { 24 }
|
||||
src = { IconClose } />
|
||||
<Text style = { styles.contextMenuItemText }>{t('breakoutRooms.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreakoutRoomContextMenu;
|
|
@ -0,0 +1,25 @@
|
|||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { isParticipantModerator } from '../../../base/participants';
|
||||
import ParticipantItem from '../../../participants-pane/components/native/ParticipantItem';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Participant to be displayed.
|
||||
*/
|
||||
item: Object
|
||||
}
|
||||
|
||||
const BreakoutRoomParticipantItem = ({ item }: Props) => (
|
||||
<ParticipantItem
|
||||
displayName = { item.displayName }
|
||||
isKnockingParticipant = { false }
|
||||
isModerator = { isParticipantModerator(item) }
|
||||
key = { item.jid }
|
||||
participantID = { item.jid } />
|
||||
);
|
||||
|
||||
export default BreakoutRoomParticipantItem;
|
|
@ -0,0 +1,72 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openDialog } from '../../../base/dialog';
|
||||
import { Icon, IconArrowDown, IconArrowUp } from '../../../base/icons';
|
||||
|
||||
import BreakoutRoomContextMenu from './BreakoutRoomContextMenu';
|
||||
import BreakoutRoomParticipantItem from './BreakoutRoomParticipantItem';
|
||||
import styles from './styles';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Room to display.
|
||||
*/
|
||||
room: Object
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a key for a passed item of the list.
|
||||
*
|
||||
* @param {Object} item - The participant.
|
||||
* @returns {string} - The user ID.
|
||||
*/
|
||||
function _keyExtractor(item: Object) {
|
||||
return item.jid;
|
||||
}
|
||||
|
||||
|
||||
export const CollapsibleRoom = ({ room }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [ collapsed, setCollapsed ] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const _toggleCollapsed = useCallback(() => {
|
||||
setCollapsed(!collapsed);
|
||||
}, [ collapsed ]);
|
||||
const _openContextMenu = useCallback(() => {
|
||||
dispatch(openDialog(BreakoutRoomContextMenu, { room }));
|
||||
}, [ room ]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onLongPress = { _openContextMenu }
|
||||
onPress = { _toggleCollapsed }
|
||||
style = { styles.collapsibleRoom }>
|
||||
<TouchableOpacity
|
||||
onPress = { _toggleCollapsed }
|
||||
style = { styles.arrowIcon }>
|
||||
<Icon
|
||||
size = { 18 }
|
||||
src = { collapsed ? IconArrowDown : IconArrowUp } />
|
||||
</TouchableOpacity>
|
||||
<Text style = { styles.roomName }>
|
||||
{`${room.name || t('breakoutRooms.mainRoom')} (${Object.values(room.participants || {}).length})`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{!collapsed && <FlatList
|
||||
bounces = { false }
|
||||
data = { Object.values(room.participants || {}) }
|
||||
horizontal = { false }
|
||||
keyExtractor = { _keyExtractor }
|
||||
renderItem = { BreakoutRoomParticipantItem }
|
||||
showsHorizontalScrollIndicator = { false }
|
||||
windowSize = { 2 } />}
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'react-native-paper';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { moveToRoom } from '../../actions';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const LeaveBreakoutRoomButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onLeave = useCallback(() =>
|
||||
dispatch(moveToRoom())
|
||||
, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
accessibilityLabel = { t('breakoutRooms.actions.leaveBreakoutRoom') }
|
||||
children = { t('breakoutRooms.actions.leaveBreakoutRoom') }
|
||||
labelStyle = { styles.leaveButtonLabel }
|
||||
mode = 'contained'
|
||||
onPress = { onLeave }
|
||||
style = { styles.transparentButton } />
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaveBreakoutRoomButton;
|
|
@ -0,0 +1,5 @@
|
|||
// @flow
|
||||
|
||||
export { default as AddBreakoutRoomButton } from './AddBreakoutRoomButton';
|
||||
export { default as AutoAssignButton } from './AutoAssignButton';
|
||||
export { default as LeaveBreakoutRoomButton } from './LeaveBreakoutRoomButton';
|
|
@ -0,0 +1,69 @@
|
|||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
const baseButton = {
|
||||
height: BaseTheme.spacing[6],
|
||||
marginTop: BaseTheme.spacing[2],
|
||||
marginLeft: BaseTheme.spacing[3],
|
||||
marginRight: BaseTheme.spacing[3]
|
||||
};
|
||||
|
||||
const baseLabel = {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
textTransform: 'capitalize'
|
||||
};
|
||||
|
||||
/**
|
||||
* The styles of the native components of the feature {@code breakout rooms}.
|
||||
*/
|
||||
export default {
|
||||
|
||||
addButtonLabel: {
|
||||
...baseLabel,
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
addButton: {
|
||||
...baseButton,
|
||||
backgroundColor: BaseTheme.palette.ui03
|
||||
},
|
||||
|
||||
collapsibleRoom: {
|
||||
...baseButton,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
arrowIcon: {
|
||||
backgroundColor: BaseTheme.palette.ui03,
|
||||
height: BaseTheme.spacing[5],
|
||||
width: BaseTheme.spacing[5],
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
|
||||
roomName: {
|
||||
fontSize: 15,
|
||||
color: BaseTheme.palette.text01,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: BaseTheme.spacing[2]
|
||||
},
|
||||
|
||||
transparentButton: {
|
||||
...baseButton,
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
|
||||
leaveButtonLabel: {
|
||||
...baseLabel,
|
||||
color: BaseTheme.palette.textError
|
||||
},
|
||||
|
||||
autoAssignLabel: {
|
||||
...baseLabel,
|
||||
color: BaseTheme.palette.link01
|
||||
}
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
|
||||
import { createBreakoutRoom } from '../../actions';
|
||||
|
||||
const useStyles = makeStyles(() => {
|
||||
return {
|
||||
button: {
|
||||
width: '100%'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const AddBreakoutRoomButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles();
|
||||
|
||||
const onAdd = useCallback(() =>
|
||||
dispatch(createBreakoutRoom())
|
||||
, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<ParticipantPaneBaseButton
|
||||
accessibilityLabel = { t('breakoutRooms.actions.add') }
|
||||
className = { styles.button }
|
||||
onClick = { onAdd }>
|
||||
{t('breakoutRooms.actions.add')}
|
||||
</ParticipantPaneBaseButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
|
||||
import { autoAssignToBreakoutRooms } from '../../actions';
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
button: {
|
||||
color: theme.palette.link01,
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const AutoAssignButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles();
|
||||
|
||||
const onAutoAssign = useCallback(() => {
|
||||
dispatch(autoAssignToBreakoutRooms());
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<ParticipantPaneBaseButton
|
||||
accessibilityLabel = { t('breakoutRooms.actions.autoAssign') }
|
||||
className = { styles.button }
|
||||
onClick = { onAutoAssign }>
|
||||
{t('breakoutRooms.actions.autoAssign')}
|
||||
</ParticipantPaneBaseButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,127 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ListItem } from '../../../base/components';
|
||||
import { Icon, IconArrowDown, IconArrowUp } from '../../../base/icons';
|
||||
import ParticipantItem from '../../../participants-pane/components/web/ParticipantItem';
|
||||
import { ACTION_TRIGGER } from '../../../participants-pane/constants';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Type of trigger for the breakout room actions.
|
||||
*/
|
||||
actionsTrigger?: string,
|
||||
|
||||
/**
|
||||
* React children.
|
||||
*/
|
||||
children: React$Node,
|
||||
|
||||
/**
|
||||
* Is this item highlighted/raised.
|
||||
*/
|
||||
isHighlighted?: boolean,
|
||||
|
||||
/**
|
||||
* Callback to raise menu. Used to raise menu on mobile long press.
|
||||
*/
|
||||
onRaiseMenu: Function,
|
||||
|
||||
/**
|
||||
* Callback for when the mouse leaves this component.
|
||||
*/
|
||||
onLeave?: Function,
|
||||
|
||||
/**
|
||||
* Room reference.
|
||||
*/
|
||||
room: Object,
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
container: {
|
||||
boxShadow: 'none'
|
||||
},
|
||||
|
||||
roomName: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...theme.typography.labelButton,
|
||||
lineHeight: `${theme.typography.labelButton.lineHeight}px`,
|
||||
padding: '12px 0'
|
||||
},
|
||||
|
||||
arrowContainer: {
|
||||
backgroundColor: theme.palette.ui03,
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '6px',
|
||||
marginRight: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const CollapsibleRoom = ({
|
||||
actionsTrigger = ACTION_TRIGGER.HOVER,
|
||||
children,
|
||||
isHighlighted,
|
||||
onRaiseMenu,
|
||||
onLeave,
|
||||
room
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const styles = useStyles();
|
||||
const [ collapsed, setCollapsed ] = useState(false);
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed(!collapsed);
|
||||
}, [ collapsed ]);
|
||||
const raiseMenu = useCallback(target => {
|
||||
onRaiseMenu(target);
|
||||
}, [ onRaiseMenu ]);
|
||||
|
||||
const arrow = (<div className = { styles.arrowContainer }>
|
||||
<Icon
|
||||
size = { 14 }
|
||||
src = { collapsed ? IconArrowDown : IconArrowUp } />
|
||||
</div>);
|
||||
|
||||
const roomName = (<span className = { styles.roomName }>
|
||||
{`${room.name || t('breakoutRooms.mainRoom')} (${Object.keys(room?.participants
|
||||
|| {}).length})`}
|
||||
</span>);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
actions = { children }
|
||||
className = { clsx(styles.container, 'breakout-room-container') }
|
||||
icon = { arrow }
|
||||
isHighlighted = { isHighlighted }
|
||||
onClick = { toggleCollapsed }
|
||||
onLongPress = { raiseMenu }
|
||||
onMouseLeave = { onLeave }
|
||||
testId = { room.id }
|
||||
textChildren = { roomName }
|
||||
trigger = { actionsTrigger } />
|
||||
{!collapsed && room?.participants
|
||||
&& Object.values(room?.participants || {}).map((p: Object) => (
|
||||
<ParticipantItem
|
||||
displayName = { p.displayName }
|
||||
key = { p.jid }
|
||||
local = { false }
|
||||
participantID = { p.jid } />
|
||||
))
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { QuickActionButton } from '../../../base/components';
|
||||
import { moveToRoom } from '../../actions';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The room to join.
|
||||
*/
|
||||
room: Object
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
button: {
|
||||
marginRight: `${theme.spacing(2)}px`
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const JoinActionButton = ({ room }: Props) => {
|
||||
const styles = useStyles();
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onJoinRoom = useCallback(e => {
|
||||
e.stopPropagation();
|
||||
dispatch(moveToRoom(room.jid));
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
return (<QuickActionButton
|
||||
accessibilityLabel = { t('breakoutRooms.actions.join') }
|
||||
className = { styles.button }
|
||||
onClick = { onJoinRoom }
|
||||
testId = { `join-room-${room.id}` }>
|
||||
{t('breakoutRooms.actions.join')}
|
||||
</QuickActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default JoinActionButton;
|
|
@ -0,0 +1,42 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ParticipantPaneBaseButton from '../../../participants-pane/components/web/ParticipantPaneBaseButton';
|
||||
import { moveToRoom } from '../../actions';
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
button: {
|
||||
color: theme.palette.textError,
|
||||
backgroundColor: 'transparent',
|
||||
width: '100%',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const LeaveButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const styles = useStyles();
|
||||
|
||||
const onLeave = useCallback(() => {
|
||||
dispatch(moveToRoom());
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<ParticipantPaneBaseButton
|
||||
accessibilityLabel = { t('breakoutRooms.actions.leaveBreakoutRoom') }
|
||||
className = { styles.button }
|
||||
onClick = { onLeave }>
|
||||
{t('breakoutRooms.actions.leaveBreakoutRoom')}
|
||||
</ParticipantPaneBaseButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { QuickActionButton } from '../../../base/components';
|
||||
import { Icon, IconHorizontalPoints } from '../../../base/icons';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Click handler function.
|
||||
*/
|
||||
onClick: Function
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => {
|
||||
return {
|
||||
button: {
|
||||
padding: '6px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const RoomActionEllipsis = ({ onClick }: Props) => {
|
||||
const styles = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<QuickActionButton
|
||||
accessibilityLabel = { t('breakoutRooms.actions.more') }
|
||||
className = { styles.button }
|
||||
onClick = { onClick }>
|
||||
<Icon src = { IconHorizontalPoints } />
|
||||
</QuickActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomActionEllipsis;
|
|
@ -0,0 +1,100 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
|
||||
import {
|
||||
IconClose,
|
||||
IconRingGroup
|
||||
} from '../../../base/icons';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { showOverflowDrawer } from '../../../toolbox/functions.web';
|
||||
import { closeBreakoutRoom, moveToRoom, removeBreakoutRoom } from '../../actions';
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Room reference.
|
||||
*/
|
||||
entity: Object,
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget: HTMLElement,
|
||||
|
||||
/**
|
||||
* Callback for the mouse entering the component.
|
||||
*/
|
||||
onEnter: Function,
|
||||
|
||||
/**
|
||||
* Callback for the mouse leaving the component.
|
||||
*/
|
||||
onLeave: Function,
|
||||
|
||||
/**
|
||||
* Callback for making a selection in the menu.
|
||||
*/
|
||||
onSelect: Function
|
||||
};
|
||||
|
||||
export const RoomContextMenu = ({
|
||||
entity: room,
|
||||
offsetTarget,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onSelect
|
||||
}: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const isLocalModerator = useSelector(isLocalParticipantModerator);
|
||||
const _overflowDrawer = useSelector(showOverflowDrawer);
|
||||
|
||||
const onJoinRoom = useCallback(() => {
|
||||
dispatch(moveToRoom(room.id));
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
const onRemoveBreakoutRoom = useCallback(() => {
|
||||
dispatch(removeBreakoutRoom(room.jid));
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
const onCloseBreakoutRoom = useCallback(() => {
|
||||
dispatch(closeBreakoutRoom(room.id));
|
||||
}, [ dispatch, room ]);
|
||||
|
||||
const isRoomEmpty = !(room?.participants && Object.keys(room.participants).length > 0);
|
||||
|
||||
const actions = [
|
||||
_overflowDrawer ? {
|
||||
accessibilityLabel: t('breakoutRooms.actions.join'),
|
||||
icon: IconRingGroup,
|
||||
onClick: onJoinRoom,
|
||||
text: t('breakoutRooms.actions.join')
|
||||
} : null,
|
||||
!room?.isMainRoom && isLocalModerator ? {
|
||||
accessibilityLabel: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close'),
|
||||
icon: IconClose,
|
||||
id: isRoomEmpty ? `remove-room-${room?.id}` : `close-room-${room?.id}`,
|
||||
onClick: isRoomEmpty ? onRemoveBreakoutRoom : onCloseBreakoutRoom,
|
||||
text: isRoomEmpty ? t('breakoutRooms.actions.remove') : t('breakoutRooms.actions.close')
|
||||
} : null
|
||||
].filter(Boolean);
|
||||
|
||||
const lowerMenu = useCallback(() => onSelect(true));
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
entity = { room }
|
||||
isDrawerOpen = { room }
|
||||
offsetTarget = { offsetTarget }
|
||||
onClick = { lowerMenu }
|
||||
onDrawerClose = { onSelect }
|
||||
onMouseEnter = { onEnter }
|
||||
onMouseLeave = { onLeave }>
|
||||
<ContextMenuItemGroup actions = { actions } />
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,63 @@
|
|||
// @flow
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import useContextMenu from '../../../base/components/context-menu/useContextMenu';
|
||||
import { getParticipantCount, isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { equals } from '../../../base/redux';
|
||||
import { showOverflowDrawer } from '../../../toolbox/functions.web';
|
||||
import { getBreakoutRooms, isInBreakoutRoom, getCurrentRoomId } from '../../functions';
|
||||
|
||||
import { AutoAssignButton } from './AutoAssignButton';
|
||||
import { CollapsibleRoom } from './CollapsibleRoom';
|
||||
import JoinActionButton from './JoinQuickActionButton';
|
||||
import { LeaveButton } from './LeaveButton';
|
||||
import RoomActionEllipsis from './RoomActionEllipsis';
|
||||
import { RoomContextMenu } from './RoomContextMenu';
|
||||
|
||||
export const RoomList = () => {
|
||||
const currentRoomId = useSelector(getCurrentRoomId);
|
||||
const rooms = Object.values(useSelector(getBreakoutRooms, equals))
|
||||
.filter((room: Object) => room.id !== currentRoomId)
|
||||
.sort((p1: Object, p2: Object) => (p1?.name || '').localeCompare(p2?.name || ''));
|
||||
const inBreakoutRoom = useSelector(isInBreakoutRoom);
|
||||
const isLocalModerator = useSelector(isLocalParticipantModerator);
|
||||
const participantsCount = useSelector(getParticipantCount);
|
||||
const _overflowDrawer = useSelector(showOverflowDrawer);
|
||||
const [ lowerMenu, raiseMenu, toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu();
|
||||
|
||||
const onRaiseMenu = useCallback(room => target => raiseMenu(room, target), [ raiseMenu ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{inBreakoutRoom && <LeaveButton />}
|
||||
{!inBreakoutRoom
|
||||
&& isLocalModerator
|
||||
&& participantsCount > 2
|
||||
&& rooms.length > 1
|
||||
&& <AutoAssignButton />}
|
||||
<div id = 'breakout-rooms-list'>
|
||||
{rooms.map((room: Object) => (
|
||||
<React.Fragment key = { room.id }>
|
||||
<CollapsibleRoom
|
||||
isHighlighted = { raiseContext.entity === room }
|
||||
onLeave = { lowerMenu }
|
||||
onRaiseMenu = { onRaiseMenu(room) }
|
||||
room = { room }>
|
||||
{!_overflowDrawer && <>
|
||||
<JoinActionButton room = { room } />
|
||||
{isLocalModerator && <RoomActionEllipsis onClick = { toggleMenu(room) } />}
|
||||
</>}
|
||||
</CollapsibleRoom>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<RoomContextMenu
|
||||
onEnter = { menuEnter }
|
||||
onLeave = { menuLeave }
|
||||
onSelect = { lowerMenu }
|
||||
{ ...raiseContext } />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
// @flow
|
||||
|
||||
export * from './LeaveButton';
|
||||
export * from './RoomList';
|
|
@ -0,0 +1,22 @@
|
|||
// @flow
|
||||
|
||||
/**
|
||||
* Key for this feature.
|
||||
*/
|
||||
export const FEATURE_KEY = 'features/breakout-rooms';
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries
|
||||
* a request for a participant to move to a specified room.
|
||||
*/
|
||||
export const JSON_TYPE_MOVE_TO_ROOM_REQUEST = `${FEATURE_KEY}/move-to-room`;
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries a request to remove a specified breakout room.
|
||||
*/
|
||||
export const JSON_TYPE_REMOVE_BREAKOUT_ROOM = `${FEATURE_KEY}/remove`;
|
||||
|
||||
/**
|
||||
* The type of json-message which indicates that json carries breakout rooms data.
|
||||
*/
|
||||
export const JSON_TYPE_UPDATE_BREAKOUT_ROOMS = `${FEATURE_KEY}/update`;
|
|
@ -0,0 +1,59 @@
|
|||
// @flow
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import { getCurrentConference } from '../base/conference';
|
||||
import { toState } from '../base/redux';
|
||||
|
||||
import { FEATURE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Returns the rooms object for breakout rooms.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store, the redux
|
||||
* {@code getState} function, or the redux state itself.
|
||||
* @returns {Object} Object of rooms.
|
||||
*/
|
||||
export const getBreakoutRooms = (stateful: Function | Object) => toState(stateful)[FEATURE_KEY].rooms;
|
||||
|
||||
/**
|
||||
* Returns the main room.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store, the redux
|
||||
* {@code getState} function, or the redux state itself.
|
||||
* @returns {Object|undefined} The main room object, or undefined.
|
||||
*/
|
||||
export const getMainRoom = (stateful: Function | Object) => {
|
||||
const rooms = getBreakoutRooms(stateful);
|
||||
|
||||
return _.find(rooms, (room: Object) => room.isMainRoom);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the id of the current room.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store, the redux
|
||||
* {@code getState} function, or the redux state itself.
|
||||
* @returns {string} Room id or undefined.
|
||||
*/
|
||||
export const getCurrentRoomId = (stateful: Function | Object) => {
|
||||
const conference = getCurrentConference(stateful);
|
||||
|
||||
// $FlowExpectedError
|
||||
return conference?.getName();
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the local participant is in a breakout room.
|
||||
*
|
||||
* @param {Function|Object} stateful - The redux store, the redux
|
||||
* {@code getState} function, or the redux state itself.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isInBreakoutRoom = (stateful: Function | Object) => {
|
||||
const conference = getCurrentConference(stateful);
|
||||
|
||||
// $FlowExpectedError
|
||||
return conference?.getBreakoutRooms()
|
||||
?.isBreakoutRoom();
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
// @flow
|
||||
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
import { FEATURE_KEY } from './constants';
|
||||
|
||||
export default getLogger(FEATURE_KEY);
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
|
||||
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
|
||||
import { StateListenerRegistry } from '../base/redux';
|
||||
|
||||
import { UPDATE_BREAKOUT_ROOMS } from './actionTypes';
|
||||
import { moveToRoom } from './actions';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Registers a change handler for state['features/base/conference'].conference to
|
||||
* set the event listeners needed for the breakout rooms feature to operate.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
state => state['features/base/conference'].conference,
|
||||
(conference, { dispatch }, previousConference) => {
|
||||
if (conference && !previousConference) {
|
||||
conference.on(JitsiConferenceEvents.BREAKOUT_ROOMS_MOVE_TO_ROOM, roomId => {
|
||||
logger.debug(`Moving to room: ${roomId}`);
|
||||
dispatch(moveToRoom(roomId));
|
||||
});
|
||||
|
||||
conference.on(JitsiConferenceEvents.BREAKOUT_ROOMS_UPDATED, rooms => {
|
||||
logger.debug('Room list updated');
|
||||
dispatch({
|
||||
type: UPDATE_BREAKOUT_ROOMS,
|
||||
rooms
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
// @flow
|
||||
|
||||
import { ReducerRegistry } from '../base/redux';
|
||||
|
||||
import { UPDATE_BREAKOUT_ROOMS } from './actionTypes';
|
||||
import { FEATURE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Listen for actions for the breakout-rooms feature.
|
||||
*/
|
||||
ReducerRegistry.register(FEATURE_KEY, (state = { rooms: {} }, action) => {
|
||||
switch (action.type) {
|
||||
case UPDATE_BREAKOUT_ROOMS: {
|
||||
const { nextIndex, rooms } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
nextIndex,
|
||||
rooms
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
|
@ -10,6 +10,7 @@ import { Icon, IconAddPeople } from '../../../base/icons';
|
|||
import { getParticipantCountWithFake } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { StyleType } from '../../../base/styles';
|
||||
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
|
||||
import { doInvitePeople } from '../../../invite/actions.native';
|
||||
|
||||
import styles from './styles';
|
||||
|
@ -19,6 +20,11 @@ import styles from './styles';
|
|||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* True if currently in a breakout room.
|
||||
*/
|
||||
_isInBreakoutRoom: boolean,
|
||||
|
||||
/**
|
||||
* True if the invite functions (dial out, invite, share...etc) are disabled.
|
||||
*/
|
||||
|
@ -66,7 +72,13 @@ class LonelyMeetingExperience extends PureComponent<Props> {
|
|||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { _isInviteFunctionsDiabled, _isLonelyMeeting, _styles, t } = this.props;
|
||||
const {
|
||||
_isInBreakoutRoom,
|
||||
_isInviteFunctionsDiabled,
|
||||
_isLonelyMeeting,
|
||||
_styles,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!_isLonelyMeeting) {
|
||||
return null;
|
||||
|
@ -81,7 +93,7 @@ class LonelyMeetingExperience extends PureComponent<Props> {
|
|||
] }>
|
||||
{ t('lonelyMeetingExperience.youAreAlone') }
|
||||
</Text>
|
||||
{ !_isInviteFunctionsDiabled && (
|
||||
{ !_isInviteFunctionsDiabled && !_isInBreakoutRoom && (
|
||||
<TouchableOpacity
|
||||
onPress = { this._onPress }
|
||||
style = { [
|
||||
|
@ -128,8 +140,10 @@ function _mapStateToProps(state): $Shape<Props> {
|
|||
const { disableInviteFunctions } = state['features/base/config'];
|
||||
const { conference } = state['features/base/conference'];
|
||||
const flag = getFeatureFlag(state, INVITE_ENABLED, true);
|
||||
const _isInBreakoutRoom = isInBreakoutRoom(state);
|
||||
|
||||
return {
|
||||
_isInBreakoutRoom,
|
||||
_isInviteFunctionsDiabled: !flag || disableInviteFunctions,
|
||||
_isLonelyMeeting: conference && getParticipantCountWithFake(state) === 1,
|
||||
_styles: ColorSchemeRegistry.get(state, 'Conference')
|
||||
|
|
|
@ -9,6 +9,7 @@ import { Icon, IconInviteMore } from '../../../base/icons';
|
|||
import { getLocalParticipant, getParticipantCountWithFake, getRemoteParticipants } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { normalizeAccents } from '../../../base/util/strings';
|
||||
import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
|
||||
import { doInvitePeople } from '../../../invite/actions.native';
|
||||
import { shouldRenderInviteButton } from '../../functions';
|
||||
|
||||
|
@ -19,6 +20,11 @@ import styles from './styles';
|
|||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Current breakout room, if we are in one.
|
||||
*/
|
||||
_currentRoom: ?Object,
|
||||
|
||||
/**
|
||||
* The local participant.
|
||||
*/
|
||||
|
@ -186,6 +192,7 @@ class MeetingParticipantList extends PureComponent<Props, State> {
|
|||
*/
|
||||
render() {
|
||||
const {
|
||||
_currentRoom,
|
||||
_localParticipant,
|
||||
_participantsCount,
|
||||
_showInviteButton,
|
||||
|
@ -197,8 +204,11 @@ class MeetingParticipantList extends PureComponent<Props, State> {
|
|||
<View
|
||||
style = { styles.meetingListContainer }>
|
||||
<Text style = { styles.meetingListDescription }>
|
||||
{t('participantsPane.headings.participantsList',
|
||||
{ count: _participantsCount })}
|
||||
{_currentRoom?.name
|
||||
|
||||
// $FlowExpectedError
|
||||
? `${_currentRoom.name} (${_participantsCount})`
|
||||
: t('participantsPane.headings.participantsList', { count: _participantsCount })}
|
||||
</Text>
|
||||
{
|
||||
_showInviteButton
|
||||
|
@ -241,8 +251,11 @@ function _mapStateToProps(state): Object {
|
|||
const { remoteParticipants } = state['features/filmstrip'];
|
||||
const _showInviteButton = shouldRenderInviteButton(state);
|
||||
const _remoteParticipants = getRemoteParticipants(state);
|
||||
const currentRoomId = getCurrentRoomId(state);
|
||||
const _currentRoom = getBreakoutRooms(state)[currentRoomId];
|
||||
|
||||
return {
|
||||
_currentRoom,
|
||||
_participantsCount,
|
||||
_remoteParticipants,
|
||||
_showInviteButton,
|
||||
|
|
|
@ -17,7 +17,7 @@ type Props = {
|
|||
/**
|
||||
* Media state for audio.
|
||||
*/
|
||||
audioMediaState: MediaState,
|
||||
audioMediaState?: MediaState,
|
||||
|
||||
/**
|
||||
* React children.
|
||||
|
@ -47,7 +47,7 @@ type Props = {
|
|||
/**
|
||||
* True if the participant is local.
|
||||
*/
|
||||
local: boolean,
|
||||
local?: boolean,
|
||||
|
||||
/**
|
||||
* Callback to be invoked on pressing the participant item.
|
||||
|
@ -62,12 +62,12 @@ type Props = {
|
|||
/**
|
||||
* True if the participant have raised hand.
|
||||
*/
|
||||
raisedHand: boolean,
|
||||
raisedHand?: boolean,
|
||||
|
||||
/**
|
||||
* Media state for video.
|
||||
*/
|
||||
videoMediaState: MediaState
|
||||
videoMediaState?: MediaState
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -98,6 +98,7 @@ function ParticipantItem({
|
|||
style = { styles.participantContent }>
|
||||
<Avatar
|
||||
className = 'participant-avatar'
|
||||
displayName = { displayName }
|
||||
participantId = { participantID }
|
||||
size = { 32 } />
|
||||
<View style = { styles.participantDetailsContainer }>
|
||||
|
|
|
@ -9,8 +9,17 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||
import { openDialog } from '../../../base/dialog';
|
||||
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
|
||||
import {
|
||||
getParticipantCount,
|
||||
isLocalParticipantModerator
|
||||
} from '../../../base/participants';
|
||||
import { equals } from '../../../base/redux';
|
||||
import {
|
||||
AddBreakoutRoomButton,
|
||||
AutoAssignButton,
|
||||
LeaveBreakoutRoomButton
|
||||
} from '../../../breakout-rooms/components/native';
|
||||
import { CollapsibleRoom } from '../../../breakout-rooms/components/native/CollapsibleRoom';
|
||||
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
|
||||
import MuteEveryoneDialog
|
||||
from '../../../video-menu/components/native/MuteEveryoneDialog';
|
||||
|
||||
|
@ -33,12 +42,36 @@ const ParticipantsPane = () => {
|
|||
[ dispatch ]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { hideAddRoomButton } = useSelector(state => state['features/base/config']);
|
||||
const { conference } = useSelector(state => state['features/base/conference']);
|
||||
|
||||
// $FlowExpectedError
|
||||
const _isBreakoutRoomsSupported = conference?.getBreakoutRooms()?.isSupported();
|
||||
const currentRoomId = useSelector(getCurrentRoomId);
|
||||
const rooms: Array<Object> = Object.values(useSelector(getBreakoutRooms, equals))
|
||||
.filter((room: Object) => room.id !== currentRoomId)
|
||||
.sort((p1: Object, p2: Object) => (p1?.name || '').localeCompare(p2?.name || ''));
|
||||
const inBreakoutRoom = useSelector(isInBreakoutRoom);
|
||||
const participantsCount = useSelector(getParticipantCount);
|
||||
|
||||
return (
|
||||
<JitsiScreen
|
||||
style = { styles.participantsPane }>
|
||||
<ScrollView bounces = { false }>
|
||||
<LobbyParticipantList />
|
||||
<MeetingParticipantList />
|
||||
{!inBreakoutRoom
|
||||
&& isLocalModerator
|
||||
&& participantsCount > 2
|
||||
&& rooms.length > 1
|
||||
&& <AutoAssignButton />}
|
||||
{inBreakoutRoom && <LeaveBreakoutRoomButton />}
|
||||
{_isBreakoutRoomsSupported
|
||||
&& rooms.map(room => (<CollapsibleRoom
|
||||
key = { room.id }
|
||||
room = { room } />))}
|
||||
{_isBreakoutRoomsSupported && !hideAddRoomButton && isLocalModerator
|
||||
&& <AddBreakoutRoomButton />}
|
||||
</ScrollView>
|
||||
{
|
||||
isLocalModerator
|
||||
|
|
|
@ -15,8 +15,7 @@ import {
|
|||
isEnabled as isAvModerationEnabled,
|
||||
isSupported as isAvModerationSupported
|
||||
} from '../../../av-moderation/functions';
|
||||
import ContextMenu from '../../../base/components/context-menu/ContextMenu';
|
||||
import ContextMenuItemGroup from '../../../base/components/context-menu/ContextMenuItemGroup';
|
||||
import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
|
||||
import { openDialog } from '../../../base/dialog';
|
||||
import { IconCheck, IconVideoOff } from '../../../base/icons';
|
||||
import { MEDIA_TYPE } from '../../../base/media';
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { makeStyles } from '@material-ui/styles';
|
||||
import React from 'react';
|
||||
|
||||
import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
|
||||
import { QuickActionButton } from '../../../base/components';
|
||||
|
||||
type Props = {
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
// @flow
|
||||
import { withStyles } from '@material-ui/styles';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { approveParticipant } from '../../../av-moderation/actions';
|
||||
import { Avatar } from '../../../base/avatar';
|
||||
import ContextMenu from '../../../base/components/context-menu/ContextMenu';
|
||||
import ContextMenuItemGroup from '../../../base/components/context-menu/ContextMenuItemGroup';
|
||||
import { ContextMenu, ContextMenuItemGroup } from '../../../base/components';
|
||||
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
|
||||
import { openDialog } from '../../../base/dialog';
|
||||
import { isIosMobileBrowser } from '../../../base/environment/utils';
|
||||
|
@ -16,6 +16,7 @@ import {
|
|||
IconMicDisabled,
|
||||
IconMicrophone,
|
||||
IconMuteEveryoneElse,
|
||||
IconRingGroup,
|
||||
IconShareVideo,
|
||||
IconVideoOff
|
||||
} from '../../../base/icons';
|
||||
|
@ -28,6 +29,8 @@ import {
|
|||
} from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
|
||||
import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
|
||||
import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
|
||||
import { openChatById } from '../../../chat/actions';
|
||||
import { setVolume } from '../../../filmstrip/actions.web';
|
||||
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
|
||||
|
@ -42,6 +45,11 @@ type Props = {
|
|||
*/
|
||||
_isAudioForceMuted: boolean,
|
||||
|
||||
/**
|
||||
* The id of the current room.
|
||||
*/
|
||||
_currentRoomId: String,
|
||||
|
||||
/**
|
||||
* True if the local participant is moderator and false otherwise.
|
||||
*/
|
||||
|
@ -82,6 +90,11 @@ type Props = {
|
|||
*/
|
||||
_participant: Object,
|
||||
|
||||
/**
|
||||
* Rooms reference.
|
||||
*/
|
||||
_rooms: Array<Object>,
|
||||
|
||||
/**
|
||||
* A value between 0 and 1 indicating the volume of the participant's
|
||||
* audio element.
|
||||
|
@ -117,7 +130,7 @@ type Props = {
|
|||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget: HTMLElement,
|
||||
offsetTarget?: HTMLElement,
|
||||
|
||||
/**
|
||||
* Callback for the mouse entering the component.
|
||||
|
@ -137,7 +150,7 @@ type Props = {
|
|||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID: string,
|
||||
participantID?: string,
|
||||
|
||||
/**
|
||||
* True if an overflow drawer should be displayed.
|
||||
|
@ -150,6 +163,20 @@ type Props = {
|
|||
t: Function
|
||||
};
|
||||
|
||||
const styles = theme => {
|
||||
return {
|
||||
text: {
|
||||
color: theme.palette.text02,
|
||||
padding: '10px 16px',
|
||||
height: '40px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements the MeetingParticipantContextMenu component.
|
||||
*/
|
||||
|
@ -170,6 +197,7 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
this._onMuteVideo = this._onMuteVideo.bind(this);
|
||||
this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
|
||||
this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
|
||||
this._onSendToRoom = this._onSendToRoom.bind(this);
|
||||
this._onVolumeChange = this._onVolumeChange.bind(this);
|
||||
this._onAskToUnmute = this._onAskToUnmute.bind(this);
|
||||
}
|
||||
|
@ -265,6 +293,22 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
dispatch(openChatById(this._getCurrentParticipantId()));
|
||||
}
|
||||
|
||||
_onSendToRoom: (room: Object) => void;
|
||||
|
||||
/**
|
||||
* Sends a participant to a room.
|
||||
*
|
||||
* @param {Object} room - The room that the participant should be moved to.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSendToRoom(room: Object) {
|
||||
return () => {
|
||||
const { _participant, dispatch } = this.props;
|
||||
|
||||
dispatch(sendParticipantToRoom(_participant.id, room.id));
|
||||
};
|
||||
}
|
||||
|
||||
_onVolumeChange: (number) => void;
|
||||
|
||||
/**
|
||||
|
@ -304,6 +348,7 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
render() {
|
||||
const {
|
||||
_isAudioForceMuted,
|
||||
_currentRoomId,
|
||||
_isLocalModerator,
|
||||
_isChatButtonEnabled,
|
||||
_isParticipantModerator,
|
||||
|
@ -312,7 +357,9 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
_isVideoForceMuted,
|
||||
_localVideoOwner,
|
||||
_participant,
|
||||
_rooms,
|
||||
_volume = 1,
|
||||
classes,
|
||||
closeDrawer,
|
||||
drawerParticipant,
|
||||
offsetTarget,
|
||||
|
@ -391,6 +438,20 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
} : null
|
||||
].filter(Boolean);
|
||||
|
||||
const breakoutRoomActions = _rooms.map(room => {
|
||||
if (room.id !== _currentRoomId) {
|
||||
return {
|
||||
accessibilityLabel: room.name || t('breakoutRooms.mainRoom'),
|
||||
icon: IconRingGroup,
|
||||
onClick: this._onSendToRoom(room),
|
||||
text: room.name || t('breakoutRooms.mainRoom')
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
).filter(Boolean);
|
||||
|
||||
const actions
|
||||
= _participant?.isFakeParticipant ? (
|
||||
<>
|
||||
|
@ -406,6 +467,15 @@ class MeetingParticipantContextMenu extends Component<Props> {
|
|||
}
|
||||
|
||||
<ContextMenuItemGroup actions = { moderatorActions2 } />
|
||||
|
||||
{
|
||||
_isLocalModerator && _rooms.length > 1
|
||||
&& <ContextMenuItemGroup actions = { breakoutRoomActions } >
|
||||
<div className = { classes && classes.text }>
|
||||
{t('breakoutRooms.actions.sendToBreakoutRoom')}
|
||||
</div>
|
||||
</ContextMenuItemGroup>
|
||||
}
|
||||
{ showVolumeSlider
|
||||
&& <ContextMenuItemGroup>
|
||||
<VolumeSlider
|
||||
|
@ -456,11 +526,13 @@ function _mapStateToProps(state, ownProps): Object {
|
|||
const participant = getParticipantByIdOrUndefined(state,
|
||||
overflowDrawer ? drawerParticipant?.participantID : participantID);
|
||||
|
||||
const _currentRoomId = getCurrentRoomId(state);
|
||||
const _isLocalModerator = isLocalParticipantModerator(state);
|
||||
const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
|
||||
const _isParticipantVideoMuted = isParticipantVideoMuted(participant, state);
|
||||
const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
|
||||
const _isParticipantModerator = isParticipantModerator(participant);
|
||||
const _rooms = Object.values(getBreakoutRooms(state));
|
||||
|
||||
const { participantsVolume } = state['features/filmstrip'];
|
||||
const id = participant?.id;
|
||||
|
@ -468,6 +540,7 @@ function _mapStateToProps(state, ownProps): Object {
|
|||
|
||||
return {
|
||||
_isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
|
||||
_currentRoomId,
|
||||
_isLocalModerator,
|
||||
_isChatButtonEnabled,
|
||||
_isParticipantModerator,
|
||||
|
@ -476,8 +549,9 @@ function _mapStateToProps(state, ownProps): Object {
|
|||
_isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
|
||||
_localVideoOwner: Boolean(ownerId === localParticipantId),
|
||||
_participant: participant,
|
||||
_rooms,
|
||||
_volume: isLocal ? undefined : id ? participantsVolume[id] : undefined
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu));
|
||||
export default translate(connect(_mapStateToProps)(withStyles(styles)(MeetingParticipantContextMenu)));
|
||||
|
|
|
@ -31,7 +31,6 @@ import ParticipantActionEllipsis from './ParticipantActionEllipsis';
|
|||
import ParticipantItem from './ParticipantItem';
|
||||
import ParticipantQuickAction from './ParticipantQuickAction';
|
||||
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
|
@ -62,7 +61,7 @@ type Props = {
|
|||
/**
|
||||
* True if the participant is the local participant.
|
||||
*/
|
||||
_local: Boolean,
|
||||
_local: boolean,
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is moderator.
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
// @flow
|
||||
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { rejectParticipantAudio } from '../../../av-moderation/actions';
|
||||
import useContextMenu from '../../../base/components/context-menu/useContextMenu';
|
||||
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
|
||||
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
|
||||
import { MEDIA_TYPE } from '../../../base/media';
|
||||
|
@ -14,9 +15,10 @@ import {
|
|||
} from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { normalizeAccents } from '../../../base/util/strings';
|
||||
import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
|
||||
import { showOverflowDrawer } from '../../../toolbox/functions';
|
||||
import { muteRemote } from '../../../video-menu/actions.any';
|
||||
import { findAncestorByClass, getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
|
||||
import { getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
|
||||
import { useParticipantDrawer } from '../../hooks';
|
||||
|
||||
import ClearableInput from './ClearableInput';
|
||||
|
@ -24,26 +26,6 @@ import { InviteButton } from './InviteButton';
|
|||
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
|
||||
import MeetingParticipantItems from './MeetingParticipantItems';
|
||||
|
||||
type NullProto = {
|
||||
[key: string]: any,
|
||||
__proto__: null
|
||||
};
|
||||
|
||||
type RaiseContext = NullProto | {|
|
||||
|
||||
/**
|
||||
* Target elements against which positioning calculations are made.
|
||||
*/
|
||||
offsetTarget?: HTMLElement,
|
||||
|
||||
/**
|
||||
* The ID of the participant.
|
||||
*/
|
||||
participantID ?: string,
|
||||
|};
|
||||
|
||||
const initialState = Object.freeze(Object.create(null));
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
return {
|
||||
heading: {
|
||||
|
@ -60,7 +42,8 @@ const useStyles = makeStyles(theme => {
|
|||
};
|
||||
});
|
||||
|
||||
type P = {
|
||||
type Props = {
|
||||
currentRoom: ?Object,
|
||||
participantsCount: number,
|
||||
showInviteButton: boolean,
|
||||
overflowDrawer: boolean,
|
||||
|
@ -77,57 +60,18 @@ type P = {
|
|||
*
|
||||
* @returns {ReactNode} - The component.
|
||||
*/
|
||||
function MeetingParticipants({ participantsCount, showInviteButton, overflowDrawer, sortedParticipantIds = [] }: P) {
|
||||
function MeetingParticipants({
|
||||
currentRoom,
|
||||
overflowDrawer,
|
||||
participantsCount,
|
||||
showInviteButton,
|
||||
sortedParticipantIds = []
|
||||
}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const isMouseOverMenu = useRef(false);
|
||||
|
||||
const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
|
||||
const [ searchString, setSearchString ] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const lowerMenu = useCallback(() => {
|
||||
/**
|
||||
* We are tracking mouse movement over the active participant item and
|
||||
* the context menu. Due to the order of enter/leave events, we need to
|
||||
* defer checking if the mouse is over the context menu with
|
||||
* queueMicrotask.
|
||||
*/
|
||||
window.queueMicrotask(() => {
|
||||
if (isMouseOverMenu.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (raiseContext !== initialState) {
|
||||
setRaiseContext(initialState);
|
||||
}
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const raiseMenu = useCallback((participantID, target) => {
|
||||
setRaiseContext({
|
||||
participantID,
|
||||
offsetTarget: findAncestorByClass(target, 'list-item-container')
|
||||
});
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const toggleMenu = useCallback(participantID => e => {
|
||||
const { participantID: raisedParticipant } = raiseContext;
|
||||
|
||||
if (raisedParticipant && raisedParticipant === participantID) {
|
||||
lowerMenu();
|
||||
} else {
|
||||
raiseMenu(participantID, e.target);
|
||||
}
|
||||
}, [ raiseContext ]);
|
||||
|
||||
const menuEnter = useCallback(() => {
|
||||
isMouseOverMenu.current = true;
|
||||
}, []);
|
||||
|
||||
const menuLeave = useCallback(() => {
|
||||
isMouseOverMenu.current = false;
|
||||
lowerMenu();
|
||||
}, [ lowerMenu ]);
|
||||
const [ lowerMenu, , toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu();
|
||||
|
||||
const muteAudio = useCallback(id => () => {
|
||||
dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
|
||||
|
@ -136,12 +80,12 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
|
|||
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
|
||||
|
||||
// FIXME:
|
||||
// It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is
|
||||
// It seems that useTranslation is not very scalable. Unmount 500 components that have the useTranslation hook is
|
||||
// taking more than 10s. To workaround the issue we need to pass the texts as props. This is temporary and dirty
|
||||
// solution!!!
|
||||
// One potential proper fix would be to use react-window component in order to lower the number of components
|
||||
// mounted.
|
||||
const participantActionEllipsisLabel = t('MeetingParticipantItem.ParticipantActionEllipsis.options');
|
||||
const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
|
||||
const youText = t('chat.you');
|
||||
const askUnmuteText = t('participantsPane.actions.askUnmute');
|
||||
const muteParticipantButtonText = t('dialog.muteParticipantButton');
|
||||
|
@ -151,7 +95,11 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
|
|||
return (
|
||||
<>
|
||||
<div className = { styles.heading }>
|
||||
{t('participantsPane.headings.participantsList', { count: participantsCount })}
|
||||
{currentRoom?.name
|
||||
|
||||
// $FlowExpectedError
|
||||
? `${currentRoom.name} (${participantsCount})`
|
||||
: t('participantsPane.headings.participantsList', { count: participantsCount })}
|
||||
</div>
|
||||
{showInviteButton && <InviteButton />}
|
||||
<ClearableInput
|
||||
|
@ -168,7 +116,7 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
|
|||
participantActionEllipsisLabel = { participantActionEllipsisLabel }
|
||||
participantIds = { sortedParticipantIds }
|
||||
participantsCount = { participantsCount }
|
||||
raiseContextId = { raiseContext.participantID }
|
||||
raiseContextId = { raiseContext.entity }
|
||||
searchString = { normalizeAccents(searchString) }
|
||||
toggleMenu = { toggleMenu }
|
||||
youText = { youText } />
|
||||
|
@ -177,11 +125,12 @@ function MeetingParticipants({ participantsCount, showInviteButton, overflowDraw
|
|||
closeDrawer = { closeDrawer }
|
||||
drawerParticipant = { drawerParticipant }
|
||||
muteAudio = { muteAudio }
|
||||
offsetTarget = { raiseContext?.offsetTarget }
|
||||
onEnter = { menuEnter }
|
||||
onLeave = { menuLeave }
|
||||
onSelect = { lowerMenu }
|
||||
overflowDrawer = { overflowDrawer }
|
||||
{ ...raiseContext } />
|
||||
participantID = { raiseContext?.entity } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -205,11 +154,15 @@ function _mapStateToProps(state): Object {
|
|||
|
||||
const overflowDrawer = showOverflowDrawer(state);
|
||||
|
||||
const currentRoomId = getCurrentRoomId(state);
|
||||
const currentRoom = getBreakoutRooms(state)[currentRoomId];
|
||||
|
||||
return {
|
||||
sortedParticipantIds,
|
||||
currentRoom,
|
||||
overflowDrawer,
|
||||
participantsCount,
|
||||
showInviteButton,
|
||||
overflowDrawer
|
||||
sortedParticipantIds
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { makeStyles } from '@material-ui/styles';
|
||||
import React from 'react';
|
||||
|
||||
import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
|
||||
import { QuickActionButton } from '../../../base/components';
|
||||
import { Icon, IconHorizontalPoints } from '../../../base/icons';
|
||||
|
||||
type Props = {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { makeStyles } from '@material-ui/styles';
|
|||
import React, { type Node, useCallback } from 'react';
|
||||
|
||||
import { Avatar } from '../../../base/avatar';
|
||||
import ListItem from '../../../base/components/particpants-pane-list/ListItem';
|
||||
import { ListItem } from '../../../base/components';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import {
|
||||
ACTION_TRIGGER,
|
||||
|
@ -22,17 +22,17 @@ type Props = {
|
|||
/**
|
||||
* Type of trigger for the participant actions.
|
||||
*/
|
||||
actionsTrigger: ActionTrigger,
|
||||
actionsTrigger?: ActionTrigger,
|
||||
|
||||
/**
|
||||
* Media state for audio.
|
||||
*/
|
||||
audioMediaState: MediaState,
|
||||
audioMediaState?: MediaState,
|
||||
|
||||
/**
|
||||
* React children.
|
||||
*/
|
||||
children: Node,
|
||||
children?: Node,
|
||||
|
||||
/**
|
||||
* Whether or not to disable the moderator indicator.
|
||||
|
@ -57,12 +57,12 @@ type Props = {
|
|||
/**
|
||||
* True if the participant is local.
|
||||
*/
|
||||
local: Boolean,
|
||||
local: boolean,
|
||||
|
||||
/**
|
||||
* Opens a drawer with participant actions.
|
||||
*/
|
||||
openDrawerForParticipant: Function,
|
||||
openDrawerForParticipant?: Function,
|
||||
|
||||
/**
|
||||
* Callback for when the mouse leaves this component.
|
||||
|
@ -82,12 +82,12 @@ type Props = {
|
|||
/**
|
||||
* True if the participant have raised hand.
|
||||
*/
|
||||
raisedHand: boolean,
|
||||
raisedHand?: boolean,
|
||||
|
||||
/**
|
||||
* Media state for video.
|
||||
*/
|
||||
videoMediaState: MediaState,
|
||||
videoMediaState?: MediaState,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
|
@ -97,7 +97,7 @@ type Props = {
|
|||
/**
|
||||
* The translated "you" text.
|
||||
*/
|
||||
youText: string
|
||||
youText?: string
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => {
|
||||
|
@ -147,7 +147,7 @@ function ParticipantItem({
|
|||
youText
|
||||
}: Props) {
|
||||
const onClick = useCallback(
|
||||
() => openDrawerForParticipant({
|
||||
() => openDrawerForParticipant && openDrawerForParticipant({
|
||||
participantID,
|
||||
displayName
|
||||
}));
|
||||
|
@ -157,6 +157,7 @@ function ParticipantItem({
|
|||
const icon = (
|
||||
<Avatar
|
||||
className = 'participant-avatar'
|
||||
displayName = { displayName }
|
||||
participantId = { participantID }
|
||||
size = { 32 } />
|
||||
);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { approveParticipant } from '../../../av-moderation/actions';
|
||||
import QuickActionButton from '../../../base/components/buttons/QuickActionButton';
|
||||
import { QuickActionButton } from '../../../base/components';
|
||||
import { QUICK_ACTION_BUTTON } from '../../constants';
|
||||
|
||||
type Props = {
|
||||
|
|
|
@ -9,6 +9,8 @@ import { translate } from '../../../base/i18n';
|
|||
import { Icon, IconClose, IconHorizontalPoints } from '../../../base/icons';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { AddBreakoutRoomButton } from '../../../breakout-rooms/components/web/AddBreakoutRoomButton';
|
||||
import { RoomList } from '../../../breakout-rooms/components/web/RoomList';
|
||||
import { MuteEveryoneDialog } from '../../../video-menu/components/';
|
||||
import { close } from '../../actions';
|
||||
import { classList, findAncestorByClass, getParticipantsPaneOpen } from '../../functions';
|
||||
|
@ -23,11 +25,21 @@ import MeetingParticipants from './MeetingParticipants';
|
|||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* Whether there is backend support for Breakout Rooms.
|
||||
*/
|
||||
_isBreakoutRoomsSupported: Boolean,
|
||||
|
||||
/**
|
||||
* Whether to display the context menu as a drawer.
|
||||
*/
|
||||
_overflowDrawer: boolean,
|
||||
|
||||
/**
|
||||
* Should the add breakout room button be displayed?
|
||||
*/
|
||||
_showAddRoomButton: boolean,
|
||||
|
||||
/**
|
||||
* Is the participants pane open.
|
||||
*/
|
||||
|
@ -178,7 +190,9 @@ class ParticipantsPane extends Component<Props, State> {
|
|||
*/
|
||||
render() {
|
||||
const {
|
||||
_isBreakoutRoomsSupported,
|
||||
_paneOpen,
|
||||
_showAddRoomButton,
|
||||
_showFooter,
|
||||
classes,
|
||||
t
|
||||
|
@ -211,6 +225,8 @@ class ParticipantsPane extends Component<Props, State> {
|
|||
<LobbyParticipants />
|
||||
<br className = { classes.antiCollapse } />
|
||||
<MeetingParticipants />
|
||||
{_isBreakoutRoomsSupported && <RoomList />}
|
||||
{_showAddRoomButton && <AddBreakoutRoomButton />}
|
||||
</div>
|
||||
{_showFooter && (
|
||||
<div className = { classes.footer }>
|
||||
|
@ -330,16 +346,21 @@ class ParticipantsPane extends Component<Props, State> {
|
|||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @protected
|
||||
* @returns {{
|
||||
* _paneOpen: boolean,
|
||||
* _showFooter: boolean
|
||||
* }}
|
||||
* @returns {Props}
|
||||
*/
|
||||
function _mapStateToProps(state: Object) {
|
||||
const isPaneOpen = getParticipantsPaneOpen(state);
|
||||
const { hideAddRoomButton } = state['features/base/config'];
|
||||
const { conference } = state['features/base/conference'];
|
||||
|
||||
// $FlowExpectedError
|
||||
const _isBreakoutRoomsSupported = conference?.getBreakoutRooms()?.isSupported();
|
||||
const _isLocalParticipantModerator = isLocalParticipantModerator(state);
|
||||
|
||||
return {
|
||||
_isBreakoutRoomsSupported,
|
||||
_paneOpen: isPaneOpen,
|
||||
_showAddRoomButton: _isBreakoutRoomsSupported && !hideAddRoomButton && _isLocalParticipantModerator,
|
||||
_showFooter: isPaneOpen && isLocalParticipantModerator(state)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
getRaiseHandsQueue
|
||||
} from '../base/participants/functions';
|
||||
import { toState } from '../base/redux';
|
||||
import { isInBreakoutRoom } from '../breakout-rooms/functions';
|
||||
|
||||
import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
|
||||
|
||||
|
@ -187,8 +188,9 @@ export function getQuickActionButtonType(participant: Object, isAudioMuted: Bool
|
|||
export const shouldRenderInviteButton = (state: Object) => {
|
||||
const { disableInviteFunctions } = toState(state)['features/base/config'];
|
||||
const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
|
||||
const inBreakoutRoom = isInBreakoutRoom(state);
|
||||
|
||||
return flagEnabled && !disableInviteFunctions;
|
||||
return flagEnabled && !disableInviteFunctions && !inBreakoutRoom;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,12 +8,14 @@ import { Avatar } from '../../../base/avatar';
|
|||
import { ColorSchemeRegistry } from '../../../base/color-scheme';
|
||||
import { BottomSheet, isDialogOpen } from '../../../base/dialog';
|
||||
import { KICK_OUT_ENABLED, getFeatureFlag } from '../../../base/flags';
|
||||
import { translate } from '../../../base/i18n';
|
||||
import {
|
||||
getParticipantById,
|
||||
getParticipantDisplayName
|
||||
} from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { StyleType } from '../../../base/styles';
|
||||
import { getBreakoutRooms, getCurrentRoomId } from '../../../breakout-rooms/functions';
|
||||
import PrivateMessageButton from '../../../chat/components/native/PrivateMessageButton';
|
||||
import { hideRemoteVideoMenu } from '../../actions.native';
|
||||
import ConnectionStatusButton from '../native/ConnectionStatusButton';
|
||||
|
@ -25,6 +27,7 @@ import MuteButton from './MuteButton';
|
|||
import MuteEveryoneElseButton from './MuteEveryoneElseButton';
|
||||
import MuteVideoButton from './MuteVideoButton';
|
||||
import PinButton from './PinButton';
|
||||
import SendToBreakoutRoom from './SendToBreakoutRoom';
|
||||
import styles from './styles';
|
||||
|
||||
// import VolumeSlider from './VolumeSlider';
|
||||
|
@ -52,6 +55,11 @@ type Props = {
|
|||
*/
|
||||
_bottomSheetStyles: StyleType,
|
||||
|
||||
/**
|
||||
* The id of the current room.
|
||||
*/
|
||||
_currentRoomId: String,
|
||||
|
||||
/**
|
||||
* Whether or not to display the kick button.
|
||||
*/
|
||||
|
@ -80,7 +88,17 @@ type Props = {
|
|||
/**
|
||||
* Display name of the participant retrieved from Redux.
|
||||
*/
|
||||
_participantDisplayName: string
|
||||
_participantDisplayName: string,
|
||||
|
||||
/**
|
||||
* Array containing the breakout rooms.
|
||||
*/
|
||||
_rooms: Array<Object>,
|
||||
|
||||
/**
|
||||
* Translation function.
|
||||
*/
|
||||
t: Function
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
|
@ -113,7 +131,10 @@ class RemoteVideoMenu extends PureComponent<Props> {
|
|||
_disableRemoteMute,
|
||||
_disableGrantModerator,
|
||||
_isParticipantAvailable,
|
||||
participantId
|
||||
_rooms,
|
||||
_currentRoomId,
|
||||
participantId,
|
||||
t
|
||||
} = this.props;
|
||||
const buttonProps = {
|
||||
afterClick: this._onCancel,
|
||||
|
@ -137,7 +158,18 @@ class RemoteVideoMenu extends PureComponent<Props> {
|
|||
<PinButton { ...buttonProps } />
|
||||
<PrivateMessageButton { ...buttonProps } />
|
||||
<ConnectionStatusButton { ...buttonProps } />
|
||||
{/* <Divider style = { styles.divider } />*/}
|
||||
{_rooms.length > 1 && <>
|
||||
<Divider style = { styles.divider } />
|
||||
<View style = { styles.contextMenuItem }>
|
||||
<Text style = { styles.contextMenuItemText }>
|
||||
{t('breakoutRooms.actions.sendToBreakoutRoom')}
|
||||
</Text>
|
||||
</View>
|
||||
{_rooms.map(room => _currentRoomId !== room.id && (<SendToBreakoutRoom
|
||||
key = { room.id }
|
||||
room = { room }
|
||||
{ ...buttonProps } />))}
|
||||
</>}
|
||||
{/* <VolumeSlider participantID = { participantId } />*/}
|
||||
</BottomSheet>
|
||||
);
|
||||
|
@ -201,19 +233,23 @@ function _mapStateToProps(state, ownProps) {
|
|||
const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config'];
|
||||
const isParticipantAvailable = getParticipantById(state, participantId);
|
||||
let { disableKick } = remoteVideoMenu;
|
||||
const _rooms = Object.values(getBreakoutRooms(state));
|
||||
const _currentRoomId = getCurrentRoomId(state);
|
||||
|
||||
disableKick = disableKick || !kickOutEnabled;
|
||||
|
||||
return {
|
||||
_bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'),
|
||||
_currentRoomId,
|
||||
_disableKick: Boolean(disableKick),
|
||||
_disableRemoteMute: Boolean(disableRemoteMute),
|
||||
_isOpen: isDialogOpen(state, RemoteVideoMenu_),
|
||||
_isParticipantAvailable: Boolean(isParticipantAvailable),
|
||||
_participantDisplayName: getParticipantDisplayName(state, participantId)
|
||||
_participantDisplayName: getParticipantDisplayName(state, participantId),
|
||||
_rooms
|
||||
};
|
||||
}
|
||||
|
||||
RemoteVideoMenu_ = connect(_mapStateToProps)(RemoteVideoMenu);
|
||||
RemoteVideoMenu_ = translate(connect(_mapStateToProps)(RemoteVideoMenu));
|
||||
|
||||
export default RemoteVideoMenu_;
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
// @flow
|
||||
|
||||
import { translate } from '../../../base/i18n';
|
||||
import { IconRingGroup } from '../../../base/icons';
|
||||
import { isLocalParticipantModerator } from '../../../base/participants';
|
||||
import { connect } from '../../../base/redux';
|
||||
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
|
||||
import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
|
||||
|
||||
export type Props = AbstractButtonProps & {
|
||||
|
||||
/**
|
||||
* The redux {@code dispatch} function.
|
||||
*/
|
||||
dispatch: Function,
|
||||
|
||||
/**
|
||||
* ID of the participant to send to breakout room.
|
||||
*/
|
||||
participantID: string,
|
||||
|
||||
/**
|
||||
* Room to send participant to.
|
||||
*/
|
||||
room: Object,
|
||||
|
||||
/**
|
||||
* Translation function.
|
||||
*/
|
||||
t: Function
|
||||
};
|
||||
|
||||
/**
|
||||
* An abstract remote video menu button which sends the remote participant to a breakout room.
|
||||
*/
|
||||
class SendToBreakoutRoom extends AbstractButton<Props, *> {
|
||||
accessibilityLabel = 'breakoutRooms.actions.sendToBreakoutRoom';
|
||||
icon = IconRingGroup;
|
||||
|
||||
/**
|
||||
* Gets the current label.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLabel() {
|
||||
const { t, room } = this.props;
|
||||
|
||||
return room.name || t('breakoutRooms.mainRoom');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and asks the participant to unmute.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_handleClick() {
|
||||
const { dispatch, participantID, room } = this.props;
|
||||
|
||||
dispatch(sendParticipantToRoom(participantID, room.id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - Properties of component.
|
||||
* @returns {Props}
|
||||
*/
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
visible: isLocalParticipantModerator(state)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(SendToBreakoutRoom));
|
|
@ -86,5 +86,19 @@ export default createStyleSheet({
|
|||
toggleLabel: {
|
||||
marginRight: BaseTheme.spacing[3],
|
||||
maxWidth: '70%'
|
||||
},
|
||||
|
||||
contextMenuItem: {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: BaseTheme.spacing[7],
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
contextMenuItemText: {
|
||||
...BaseTheme.typography.bodyShortRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginLeft: BaseTheme.spacing[4]
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,555 @@
|
|||
-- This module is added under the main virtual host domain
|
||||
-- It needs a breakout rooms muc component
|
||||
--
|
||||
-- VirtualHost "jitmeet.example.com"
|
||||
-- modules_enabled = {
|
||||
-- "muc_breakout_rooms"
|
||||
-- }
|
||||
-- breakout_rooms_muc = "breakout.jitmeet.example.com"
|
||||
-- main_muc = "muc.jitmeet.example.com"
|
||||
--
|
||||
-- Component "breakout.jitmeet.example.com" "muc"
|
||||
-- restrict_room_creation = true
|
||||
-- storage = "memory"
|
||||
-- modules_enabled = {
|
||||
-- "muc_meeting_id";
|
||||
-- "muc_domain_mapper";
|
||||
-- --"token_verification";
|
||||
-- }
|
||||
-- admins = { "focusUser@auth.jitmeet.example.com" }
|
||||
-- muc_room_locking = false
|
||||
-- muc_room_default_public_jids = true
|
||||
--
|
||||
-- we use async to detect Prosody 0.10 and earlier
|
||||
local have_async = pcall(require, 'util.async');
|
||||
|
||||
if not have_async then
|
||||
module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.');
|
||||
return;
|
||||
end
|
||||
|
||||
local jid_bare = require 'util.jid'.bare;
|
||||
local jid_node = require 'util.jid'.node;
|
||||
local jid_host = require 'util.jid'.host;
|
||||
local jid_resource = require 'util.jid'.resource;
|
||||
local jid_split = require 'util.jid'.split;
|
||||
local json = require 'util.json';
|
||||
local st = require 'util.stanza';
|
||||
local uuid_gen = require 'util.uuid'.generate;
|
||||
|
||||
local util = module:require 'util';
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
|
||||
local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms';
|
||||
-- only send at most this often updates on breakout rooms to avoid flooding.
|
||||
local BROADCAST_ROOMS_INTERVAL = .3;
|
||||
-- close conference after this amount of seconds if all leave.
|
||||
local ROOMS_TTL_IF_ALL_LEFT = 5;
|
||||
local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add';
|
||||
local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room';
|
||||
local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove';
|
||||
local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update';
|
||||
|
||||
local main_muc_component_config = module:get_option_string('main_muc');
|
||||
if main_muc_component_config == nil then
|
||||
module:log('error', 'breakout rooms not enabled missing main_muc config');
|
||||
return ;
|
||||
end
|
||||
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
|
||||
|
||||
module:depends('jitsi_session');
|
||||
|
||||
local breakout_rooms_muc_service;
|
||||
local main_muc_service;
|
||||
|
||||
-- Maps a breakout room jid to the main room jid
|
||||
local main_rooms_map = {};
|
||||
|
||||
-- Utility functions
|
||||
|
||||
function get_main_room_jid(room_jid)
|
||||
local node, host = jid_split(room_jid);
|
||||
|
||||
return
|
||||
host == main_muc_component_config
|
||||
and room_jid
|
||||
or main_rooms_map[room_jid];
|
||||
end
|
||||
|
||||
function get_main_room(room_jid)
|
||||
local main_room_jid = get_main_room_jid(room_jid);
|
||||
|
||||
return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid;
|
||||
end
|
||||
|
||||
function get_room_from_jid(room_jid)
|
||||
local host = jid_host(room_jid);
|
||||
|
||||
return
|
||||
host == main_muc_component_config
|
||||
and main_muc_service.get_room_from_jid(room_jid)
|
||||
or breakout_rooms_muc_service.get_room_from_jid(room_jid);
|
||||
end
|
||||
|
||||
function send_json_msg(to_jid, json_msg)
|
||||
local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
function get_participants(room)
|
||||
local participants = {};
|
||||
|
||||
if room then
|
||||
for nick, occupant in room:each_occupant() do
|
||||
-- Filter focus as we keep it as a hidden participant
|
||||
if jid_node(occupant.jid) ~= 'focus' then
|
||||
local display_name = occupant:get_presence():get_child_text(
|
||||
'nick', 'http://jabber.org/protocol/nick');
|
||||
participants[nick] = {
|
||||
jid = occupant.jid,
|
||||
role = occupant.role,
|
||||
displayName = display_name
|
||||
};
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return participants;
|
||||
end
|
||||
|
||||
function broadcast_breakout_rooms(room_jid)
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if not main_room or main_room._data.is_broadcast_breakout_scheduled then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages.
|
||||
main_room._data.is_broadcast_breakout_scheduled = true;
|
||||
main_room:save(true);
|
||||
module:add_timer(BROADCAST_ROOMS_INTERVAL, function()
|
||||
main_room._data.is_broadcast_breakout_scheduled = false;
|
||||
main_room:save(true);
|
||||
|
||||
local main_room_node = jid_node(main_room_jid)
|
||||
local rooms = {
|
||||
[main_room_node] = {
|
||||
isMainRoom = true,
|
||||
id = main_room_node,
|
||||
jid = main_room_jid,
|
||||
name = main_room._data.subject,
|
||||
participants = get_participants(main_room)
|
||||
};
|
||||
}
|
||||
|
||||
for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
local breakout_room_node = jid_node(breakout_room_jid)
|
||||
|
||||
rooms[breakout_room_node] = {
|
||||
id = breakout_room_node,
|
||||
jid = breakout_room_jid,
|
||||
name = subject,
|
||||
participants = {}
|
||||
}
|
||||
|
||||
-- The room may not physically exist yet.
|
||||
if breakout_room then
|
||||
rooms[breakout_room_node].participants = get_participants(breakout_room);
|
||||
end
|
||||
end
|
||||
|
||||
local json_msg = json.encode({
|
||||
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS,
|
||||
rooms = rooms
|
||||
});
|
||||
|
||||
for _, occupant in main_room:each_occupant() do
|
||||
if jid_node(occupant.jid) ~= 'focus' then
|
||||
send_json_msg(occupant.jid, json_msg)
|
||||
end
|
||||
end
|
||||
|
||||
for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if room then
|
||||
for _, occupant in room:each_occupant() do
|
||||
if jid_node(occupant.jid) ~= 'focus' then
|
||||
send_json_msg(occupant.jid, json_msg)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
|
||||
-- Managing breakout rooms
|
||||
|
||||
function create_breakout_room(room_jid, subject)
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
local breakout_room_jid = uuid_gen() .. '@' .. breakout_rooms_muc_component_config;
|
||||
|
||||
if not main_room._data.breakout_rooms then
|
||||
main_room._data.breakout_rooms = {};
|
||||
end
|
||||
main_room._data.breakout_rooms[breakout_room_jid] = subject;
|
||||
-- Make room persistent - not to be destroyed - if all participants join breakout rooms.
|
||||
main_room:set_persistent(true);
|
||||
main_room:save(true);
|
||||
|
||||
main_rooms_map[breakout_room_jid] = main_room_jid;
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
|
||||
function destroy_breakout_room(room_jid, message)
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if room_jid == main_room_jid then
|
||||
return;
|
||||
end
|
||||
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
|
||||
|
||||
if breakout_room then
|
||||
message = message or 'Breakout room removed.';
|
||||
breakout_room:destroy(main_room_jid, message);
|
||||
end
|
||||
if main_room then
|
||||
if main_room._data.breakout_rooms then
|
||||
main_room._data.breakout_rooms[room_jid] = nil;
|
||||
end
|
||||
main_room:save(true);
|
||||
|
||||
main_rooms_map[room_jid] = nil;
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Handling events
|
||||
|
||||
function on_message(event)
|
||||
local session = event.origin;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE);
|
||||
|
||||
if not message then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant requesting is a moderator and is an occupant in the room
|
||||
local from = event.stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if not occupant then
|
||||
log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
if occupant.role ~= 'moderator' then
|
||||
log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then
|
||||
create_breakout_room(room.jid, message.attr.subject);
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then
|
||||
destroy_breakout_room(message.attr.breakoutRoomJid);
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_MOVE_TO_ROOM_REQUEST then
|
||||
local participant_jid = message.attr.participantJid;
|
||||
local target_room_jid = message.attr.roomJid;
|
||||
|
||||
local json_msg = json.encode({
|
||||
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_MOVE_TO_ROOM_REQUEST,
|
||||
roomJid = target_room_jid
|
||||
});
|
||||
|
||||
send_json_msg(participant_jid, json_msg)
|
||||
return true;
|
||||
end
|
||||
|
||||
-- return error.
|
||||
return false;
|
||||
end
|
||||
|
||||
function on_breakout_room_pre_create(event)
|
||||
local breakout_room = event.room;
|
||||
local main_room, main_room_jid = get_main_room(breakout_room.jid);
|
||||
|
||||
-- Only allow existent breakout rooms to be started.
|
||||
-- Authorisation of breakout rooms is done by their random uuid suffix
|
||||
if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then
|
||||
breakout_room._data.subject = main_room._data.breakout_rooms[breakout_room.jid];
|
||||
breakout_room.save();
|
||||
else
|
||||
module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid);
|
||||
breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
function on_occupant_joined(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room = get_main_room(room.jid);
|
||||
|
||||
if jid_node(event.occupant.jid) ~= 'focus' then
|
||||
broadcast_breakout_rooms(room.jid);
|
||||
end
|
||||
|
||||
-- Prevent closing all rooms if a participant has joined (see on_occupant_left).
|
||||
if (main_room._data.is_close_all_scheduled) then
|
||||
main_room._data.is_close_all_scheduled = false;
|
||||
main_room:save();
|
||||
end
|
||||
end
|
||||
|
||||
function exist_occupants_in_room(room)
|
||||
if not room then
|
||||
return false;
|
||||
end
|
||||
for occupant_jid, occupant in room:each_occupant() do
|
||||
if jid_node(occupant.jid) ~= 'focus' then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
function exist_occupants_in_rooms(main_room)
|
||||
if exist_occupants_in_room(main_room) then
|
||||
return true;
|
||||
end
|
||||
for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if exist_occupants_in_room(room) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
function on_occupant_left(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
if jid_node(event.occupant.jid) ~= 'focus' then
|
||||
broadcast_breakout_rooms(room.jid);
|
||||
end
|
||||
|
||||
-- Close the conference if all left for good.
|
||||
if not main_room._data.is_close_all_scheduled and not exist_occupants_in_rooms(main_room) then
|
||||
main_room._data.is_close_all_scheduled = true;
|
||||
main_room:save(true);
|
||||
module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function()
|
||||
if main_room._data.is_close_all_scheduled then
|
||||
--module:log('info', 'Closing conference %s as all left for good.', main_room_jid);
|
||||
main_room:set_persistent(false);
|
||||
main_room:save(true);
|
||||
main_room:destroy(main_room_jid, 'All occupants left.');
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function on_main_room_destroyed(event)
|
||||
local main_room = event.room;
|
||||
|
||||
if is_healthcheck_room(main_room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local message = 'Conference ended.';
|
||||
|
||||
for breakout_room_jid, breakout_room in pairs(main_room._data.breakout_rooms or {}) do
|
||||
destroy_breakout_room(breakout_room_jid, message)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Module operations
|
||||
|
||||
-- process a host module directly if loaded or hooks to wait for its load
|
||||
function process_host_module(name, callback)
|
||||
local function process_host(host)
|
||||
if host == name then
|
||||
callback(module:context(host), host);
|
||||
end
|
||||
end
|
||||
|
||||
if prosody.hosts[name] == nil then
|
||||
module:log('debug', 'No host/component found, will wait for it: %s', name)
|
||||
|
||||
-- when a host or component is added
|
||||
prosody.events.add_handler('host-activated', process_host);
|
||||
else
|
||||
process_host(name);
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- operates on already loaded breakout rooms muc module
|
||||
function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module)
|
||||
module:log('debug', 'Breakout rooms muc loaded');
|
||||
|
||||
-- Advertise the breakout rooms component so clients can pick up the address and use it
|
||||
module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config);
|
||||
|
||||
breakout_rooms_muc_service = breakout_rooms_muc;
|
||||
module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config);
|
||||
host_module:hook('message/host', on_message);
|
||||
host_module:hook('muc-occupant-joined', on_occupant_joined);
|
||||
host_module:hook('muc-occupant-left', on_occupant_left);
|
||||
host_module:hook('muc-room-pre-create', on_breakout_room_pre_create);
|
||||
|
||||
host_module:hook('muc-disco#info', function (event)
|
||||
local room = event.room;
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
-- Breakout room matadata.
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_isbreakout';
|
||||
label = 'Is this a breakout room?';
|
||||
type = "boolean";
|
||||
});
|
||||
event.formdata['muc#roominfo_isbreakout'] = true;
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_breakout_main_room';
|
||||
label = 'The main room associated with this breakout room';
|
||||
});
|
||||
event.formdata['muc#roominfo_breakout_main_room'] = main_room_jid;
|
||||
|
||||
-- If the main room has a lobby, make it so this breakout room also uses it.
|
||||
if (main_room._data.lobbyroom and main_room:get_members_only()) then
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_lobbyroom';
|
||||
label = 'Lobby room jid';
|
||||
});
|
||||
event.formdata['muc#roominfo_lobbyroom'] = main_room._data.lobbyroom;
|
||||
end
|
||||
end);
|
||||
|
||||
host_module:hook("muc-config-form", function(event)
|
||||
local room = event.room;
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
-- Breakout room matadata.
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_isbreakout';
|
||||
label = 'Is this a breakout room?';
|
||||
type = "boolean";
|
||||
value = true;
|
||||
});
|
||||
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_breakout_main_room';
|
||||
label = 'The main room associated with this breakout room';
|
||||
value = main_room_jid;
|
||||
});
|
||||
end);
|
||||
|
||||
local room_mt = breakout_rooms_muc_service.room_mt;
|
||||
|
||||
room_mt.get_members_only = function(room)
|
||||
local main_room = get_main_room(room.jid);
|
||||
|
||||
return main_room.get_members_only(main_room)
|
||||
end
|
||||
|
||||
-- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc
|
||||
room_mt.get_affiliation = function(room, jid)
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
if not main_room then
|
||||
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
|
||||
return 'none';
|
||||
end
|
||||
|
||||
-- moderators in main room are moderators here
|
||||
local role = main_room.get_affiliation(main_room, jid);
|
||||
if role then
|
||||
return role;
|
||||
end
|
||||
|
||||
return 'none';
|
||||
end
|
||||
end
|
||||
|
||||
-- process or waits to process the breakout rooms muc component
|
||||
process_host_module(breakout_rooms_muc_component_config, function(host_module, host)
|
||||
module:log('info', 'Breakout rooms component created %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_breakout_rooms_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- operates on already loaded main muc module
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
module:log('debug', 'Main muc loaded');
|
||||
|
||||
main_muc_service = main_muc;
|
||||
module:log("info", "Hook to muc events on %s", main_muc_component_config);
|
||||
host_module:hook('muc-occupant-joined', on_occupant_joined);
|
||||
host_module:hook('muc-occupant-left', on_occupant_left);
|
||||
host_module:hook('muc-room-destroyed', on_main_room_destroyed);
|
||||
end
|
||||
|
||||
-- process or waits to process the main muc component
|
||||
process_host_module(main_muc_component_config, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
|
@ -113,7 +113,8 @@ function filter_stanza(stanza)
|
|||
|
||||
if from_domain == lobby_muc_component_config then
|
||||
if stanza.name == 'presence' then
|
||||
if presence_check_status(stanza:get_child('x', MUC_NS..'#user'), '110') then
|
||||
local muc_x = stanza:get_child('x', MUC_NS..'#user');
|
||||
if not muc_x or presence_check_status(muc_x, '110') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
|
@ -124,6 +125,17 @@ function filter_stanza(stanza)
|
|||
return stanza;
|
||||
end
|
||||
|
||||
-- check is an owner, only owners can receive the presence
|
||||
-- do not forward presence of owners (other than unavailable)
|
||||
local room = main_muc_service.get_room_from_jid(jid_bare(node .. '@' .. main_muc_component_config));
|
||||
local item = muc_x:get_child('item');
|
||||
if not room
|
||||
or stanza.attr.type == 'unavailable'
|
||||
or (room.get_affiliation(room, stanza.attr.to) == 'owner'
|
||||
and room.get_affiliation(room, item.attr.jid) ~= 'owner') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
|
||||
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
|
||||
if not from_occupant then
|
||||
|
|
Loading…
Reference in New Issue