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:
Werner Fleischer 2021-09-14 17:31:30 +02:00 committed by Saúl Ibarra Corretgé
parent d98ea3ca24
commit b5faf9f62a
60 changed files with 2483 additions and 132 deletions

View File

@ -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.
*

View File

@ -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,

View File

@ -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"

View File

@ -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",

View File

@ -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';

View File

@ -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';

View File

@ -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;

View File

@ -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';

View File

@ -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>
)}

View File

@ -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.

View File

@ -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.
*

View File

@ -158,6 +158,7 @@ export default [
'hideParticipantsStats',
'hideConferenceTimer',
'hiddenDomain',
'hideAddRoomButton',
'hideLobbyButton',
'hosts',
'iAmRecorder',

View File

@ -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

View File

@ -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';

View File

@ -0,0 +1,7 @@
// @flow
/**
* The type of (redux) action to update the breakout room data.
*
*/
export const UPDATE_BREAKOUT_ROOMS = 'UPDATE_BREAKOUT_ROOMS';

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -0,0 +1,5 @@
// @flow
export { default as AddBreakoutRoomButton } from './AddBreakoutRoomButton';
export { default as AutoAssignButton } from './AutoAssignButton';
export { default as LeaveBreakoutRoomButton } from './LeaveBreakoutRoomButton';

View File

@ -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
}
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 } />
))
}
</>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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 } />
</>
);
};

View File

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

View File

@ -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`;

View File

@ -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();
};

View File

@ -0,0 +1,7 @@
// @flow
import { getLogger } from '../base/logging/functions';
import { FEATURE_KEY } from './constants';
export default getLogger(FEATURE_KEY);

View File

@ -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
});
});
}
});

View File

@ -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;
});

View File

@ -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')

View File

@ -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,

View File

@ -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 }>

View File

@ -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

View File

@ -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';

View File

@ -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 = {

View File

@ -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)));

View File

@ -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.

View File

@ -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
};
}

View File

@ -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 = {

View File

@ -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 } />
);

View File

@ -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 = {

View File

@ -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)
};
}

View File

@ -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;
};
/**

View File

@ -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_;

View File

@ -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));

View File

@ -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]
}
});

View File

@ -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);

View File

@ -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