Merge remote-tracking branch 'upstream/v3.0.x-release' into remove-voice-call-state

This commit is contained in:
Tainan Felipe 2024-04-23 17:34:41 -03:00
commit 7165961444
90 changed files with 3035 additions and 1300 deletions

View File

@ -1799,10 +1799,9 @@ SELECT *
FROM "caption"
WHERE "createdAt" > current_timestamp - INTERVAL '5 seconds';
CREATE OR REPLACE VIEW "v_caption_typed_activeLocales" AS
select distinct "meetingId", "locale", "ownerUserId"
from "caption_locale"
where "captionType" = 'TYPED';
CREATE OR REPLACE VIEW "v_caption_activeLocales" AS
select distinct "meetingId", "locale", "ownerUserId", "captionType"
from "caption_locale";
create index "idx_caption_typed_activeLocales" on caption("meetingId","locale","userId") where "captionType" = 'TYPED';

View File

@ -1,10 +1,10 @@
table:
name: v_caption_typed_activeLocales
name: v_caption_activeLocales
schema: public
configuration:
column_config: {}
custom_column_names: {}
custom_name: caption_typed_activeLocales
custom_name: caption_activeLocales
custom_root_fields: {}
object_relationships:
- name: userOwner
@ -22,6 +22,7 @@ select_permissions:
permission:
columns:
- locale
- captionType
filter:
meetingId:
_eq: X-Hasura-MeetingId

View File

@ -16,7 +16,6 @@ select_permissions:
- messageDescription
- messageId
- messageValues
- notificationId
- notificationType
- role
filter:

View File

@ -3,7 +3,7 @@
- "!include public_v_breakoutRoom_participant.yaml"
- "!include public_v_breakoutRoom_user.yaml"
- "!include public_v_caption.yaml"
- "!include public_v_caption_typed_activeLocales.yaml"
- "!include public_v_caption_activeLocales.yaml"
- "!include public_v_chat.yaml"
- "!include public_v_chat_message_private.yaml"
- "!include public_v_chat_message_public.yaml"

View File

@ -9,10 +9,9 @@ import VideoStreams from '/imports/api/video-streams';
import VoiceUsers from '/imports/api/voice-users';
import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
import AuthTokenValidation from '/imports/api/auth-token-validation';
import Breakouts from '/imports/api/breakouts';
import Meetings, {
RecordMeetings, MeetingTimeRemaining, Notifications,
MeetingTimeRemaining, Notifications,
} from '/imports/api/meetings';
import Users from '/imports/api/users';
@ -31,8 +30,6 @@ export const localCollectionRegistry = {
localPadsSync: new AbstractCollection(Pads, Pads),
localPadsSessionsSync: new AbstractCollection(PadsSessions, PadsSessions),
localPadsUpdatesSync: new AbstractCollection(PadsUpdates, PadsUpdates),
localAuthTokenValidationSync: new AbstractCollection(AuthTokenValidation, AuthTokenValidation),
localRecordMeetingsSync: new AbstractCollection(RecordMeetings, RecordMeetings),
localMeetingTimeRemainingSync: new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining),
localBreakoutsSync: new AbstractCollection(Breakouts, Breakouts),
localMeetingsSync: new AbstractCollection(Meetings, Meetings),

View File

@ -23,7 +23,6 @@ import logger from '/imports/startup/client/logger';
import '/imports/ui/services/mobile-app';
import Base from '/imports/startup/client/base';
import JoinHandler from '../imports/ui/components/join-handler/component';
import AuthenticatedHandler from '/imports/ui/components/authenticated-handler/component';
import Subscriptions from '/imports/ui/components/subscriptions/component';
import IntlStartup from '/imports/startup/client/intl';
import ContextProviders from '/imports/ui/components/context-providers/component';

View File

@ -11,6 +11,10 @@ const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings
.application.microphoneConstraints;
const MEDIA_TAG = Meteor.settings.public.media.mediaTag;
const CONFIG = window.meetingClientSettings.public.app.audioCaptions;
const PROVIDER = CONFIG.provider;
const audioCaptionsEnabled = window.meetingClientSettings.public.app.audioCaptions.enabled;
const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
if (!currItem) {
@ -115,6 +119,21 @@ const doGUM = async (constraints, retryOnFailure = false) => {
}
};
const isEnabled = () => audioCaptionsEnabled;
const isWebSpeechApi = () => PROVIDER === 'webspeech';
const isVosk = () => PROVIDER === 'vosk';
const isWhispering = () => PROVIDER === 'whisper';
const isDeepSpeech = () => PROVIDER === 'deepSpeech';
const isActive = () => isEnabled()
&& ((isWebSpeechApi()) || isVosk() || isWhispering() || isDeepSpeech());
const stereoUnsupported = () => isActive() && isVosk();
export {
DEFAULT_INPUT_DEVICE_ID,
DEFAULT_OUTPUT_DEVICE_ID,
@ -131,4 +150,5 @@ export {
getStoredAudioOutputDeviceId,
storeAudioOutputDeviceId,
doGUM,
stereoUnsupported,
};

View File

@ -20,8 +20,8 @@ import {
getAudioConstraints,
filterSupportedConstraints,
doGUM,
stereoUnsupported,
} from '/imports/api/audio/client/bridge/service';
import SpeechService from '/imports/ui/components/audio/captions/speech/service';
const MEDIA = Meteor.settings.public.media;
const MEDIA_TAG = MEDIA.mediaTag;
@ -717,7 +717,7 @@ class SIPSession {
// via SDP munging. Having it disabled on server side FS _does not suffice_
// because the stereo parameter is client-mandated (ie replicated in the
// answer)
if (SpeechService.stereoUnsupported()) {
if (stereoUnsupported()) {
logger.debug({
logCode: 'sipjs_transcription_disable_stereo',
}, 'Transcription provider does not support stereo, forcing stereo=0');

View File

@ -1,14 +0,0 @@
import Logger from '/imports/startup/server/logger';
import AuthTokenValidation from '/imports/api/auth-token-validation';
export default async function removeValidationState(meetingId, userId, connectionId) {
const selector = {
meetingId, userId, connectionId,
};
try {
await AuthTokenValidation.removeAsync(selector);
} catch (error) {
Logger.error(`Could not remove from collection AuthTokenValidation: ${error}`);
}
}

View File

@ -5,7 +5,6 @@ const collectionOptions = Meteor.isClient ? {
} : {};
const Meetings = new Mongo.Collection('meetings', collectionOptions);
const RecordMeetings = new Mongo.Collection('record-meetings', collectionOptions);
const MeetingTimeRemaining = new Mongo.Collection('meeting-time-remaining', collectionOptions);
const Notifications = new Mongo.Collection('notifications', collectionOptions);
const LayoutMeetings = new Mongo.Collection('layout-meetings');
@ -15,13 +14,11 @@ if (Meteor.isServer) {
// 1. meetingId
Meetings.createIndexAsync({ meetingId: 1 });
RecordMeetings.createIndexAsync({ meetingId: 1 });
MeetingTimeRemaining.createIndexAsync({ meetingId: 1 });
LayoutMeetings.createIndexAsync({ meetingId: 1 });
}
export {
RecordMeetings,
MeetingTimeRemaining,
Notifications,
LayoutMeetings,

View File

@ -7,8 +7,6 @@ import handleMeetingLocksChange from './handlers/meetingLockChange';
import handleGuestPolicyChanged from './handlers/guestPolicyChanged';
import handleGuestLobbyMessageChanged from './handlers/guestLobbyMessageChanged';
import handleUserLockChange from './handlers/userLockChange';
import handleRecordingStatusChange from './handlers/recordingStatusChange';
import handleRecordingTimerChange from './handlers/recordingTimerChange';
import handleTimeRemainingUpdate from './handlers/timeRemainingUpdate';
import handleChangeWebcamOnlyModerator from './handlers/webcamOnlyModerator';
import handleBroadcastLayout from './handlers/broadcastLayout';
@ -23,8 +21,6 @@ RedisPubSub.on('MeetingEndingEvtMsg', handleMeetingEnd);
RedisPubSub.on('MeetingDestroyedEvtMsg', handleMeetingDestruction);
RedisPubSub.on('LockSettingsInMeetingChangedEvtMsg', handleMeetingLocksChange);
RedisPubSub.on('UserLockedInMeetingEvtMsg', handleUserLockChange);
RedisPubSub.on('RecordingStatusChangedEvtMsg', handleRecordingStatusChange);
RedisPubSub.on('UpdateRecordingTimerEvtMsg', handleRecordingTimerChange);
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
RedisPubSub.on('GetLockSettingsRespMsg', handleMeetingLocksChange);
RedisPubSub.on('GuestPolicyChangedEvtMsg', handleGuestPolicyChanged);

View File

@ -1,26 +0,0 @@
import { check } from 'meteor/check';
import { RecordMeetings } from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
export default async function handleRecordingStatusChange({ body }, meetingId) {
const { recording, setBy } = body;
check(recording, Boolean);
const selector = {
meetingId,
};
const modifier = {
$set: { recording, setBy },
};
try {
const { numberAffected } = await RecordMeetings.upsertAsync(selector, modifier);
if (numberAffected) {
Logger.info(`Changed meeting record status id=${meetingId} recording=${recording}`);
}
} catch (err) {
Logger.error(`Changing record status: ${err}`);
}
}

View File

@ -1,27 +0,0 @@
import { check } from 'meteor/check';
import { RecordMeetings } from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
export default async function handleRecordingTimerChange({ body }, meetingId) {
const { time } = body;
check(meetingId, String);
check(body, {
time: Number,
});
const selector = {
meetingId,
};
const modifier = {
$set: { time },
};
try {
await RecordMeetings.upsertAsync(selector, modifier);
} catch (err) {
Logger.error(`Changing recording time: ${err}`);
}
}

View File

@ -5,7 +5,6 @@ import {
} from 'meteor/check';
import SanitizeHTML from 'sanitize-html';
import Meetings, {
RecordMeetings,
LayoutMeetings,
} from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
@ -221,21 +220,6 @@ export default async function addMeeting(meeting) {
}
}
try {
const {
insertedId,
numberAffected,
} = await RecordMeetings.upsertAsync(selector, { meetingId, ...recordProp });
if (insertedId) {
Logger.info(`Added record prop id=${meetingId}`);
} else if (numberAffected) {
Logger.info(`Upserted record prop id=${meetingId}`);
}
} catch (err) {
Logger.error(`Adding record prop to collection: ${err}`);
}
await addLayout(meetingId, LAYOUT_TYPE[meetingLayout] || 'smart');
try {

View File

@ -1,14 +0,0 @@
import { RecordMeetings } from '/imports/api/meetings';
import Logger from '/imports/startup/server/logger';
export default async function meetingHasEnded(meetingId) {
try {
const numberAffected = RecordMeetings.removeAsync({ meetingId });
if (numberAffected) {
Logger.info(`Cleared record prop from meeting with id ${meetingId}`);
}
} catch (err) {
Logger.error(`Error on clearing record prop from meeting with id ${meetingId}. ${err}`);
}
}

View File

@ -12,7 +12,6 @@ import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserIn
import clearScreenshare from '/imports/api/screenshare/server/modifiers/clearScreenshare';
import clearTimer from '/imports/api/timer/server/modifiers/clearTimer';
import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining';
import clearRecordMeeting from './clearRecordMeeting';
import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation';
import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions';
@ -35,7 +34,6 @@ export default async function meetingHasEnded(meetingId) {
clearUserInfo(meetingId),
clearTimer(meetingId),
clearMeetingTimeRemaining(meetingId),
clearRecordMeeting(meetingId),
clearVideoStreams(meetingId),
clearAuthTokenValidation(meetingId),
clearWhiteboardMultiUser(meetingId),

View File

@ -1,7 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import Meetings, {
RecordMeetings,
MeetingTimeRemaining,
LayoutMeetings,
} from '/imports/api/meetings';
@ -72,27 +71,6 @@ function publish(...args) {
Meteor.publish('meetings', publish);
function recordMeetings() {
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
Logger.warn(`Publishing RecordMeetings was requested by unauth connection ${this.connection.id}`);
return RecordMeetings.find({ meetingId: '' });
}
const { meetingId, userId } = tokenValidation;
Logger.debug(`Publishing RecordMeetings for ${meetingId} ${userId}`);
return RecordMeetings.find({ meetingId });
}
function recordPublish(...args) {
const boundRecordMeetings = recordMeetings.bind(this);
return boundRecordMeetings(...args);
}
Meteor.publish('record-meetings', recordPublish);
function layoutMeetings() {
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });

View File

@ -1,12 +1,10 @@
import { Meteor } from 'meteor/meteor';
import validateAuthToken from './methods/validateAuthToken';
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
import userActivitySign from './methods/userActivitySign';
import validateConnection from './methods/validateConnection';
Meteor.methods({
validateConnection,
validateAuthToken,
setUserEffectiveConnectionType,
userActivitySign,
});

View File

@ -1,78 +0,0 @@
import { Meteor } from 'meteor/meteor';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import upsertValidationState from '/imports/api/auth-token-validation/server/modifiers/upsertValidationState';
import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
import pendingAuthenticationsStore from '../store/pendingAuthentications';
const AUTH_TIMEOUT = 120000;
async function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
let setTimeoutRef = null;
const userValidation = await new Promise(async (res, rej) => {
const observeFunc = (obj) => {
if (obj.validationStatus === ValidationStates.VALIDATED) {
Meteor.clearTimeout(setTimeoutRef);
return res(obj);
}
if (obj.validationStatus === ValidationStates.INVALID) {
Meteor.clearTimeout(setTimeoutRef);
return res(obj);
}
};
const authTokenValidationObserver = AuthTokenValidation.find({
connectionId: this.connection.id,
}).observe({
added: observeFunc,
changed: observeFunc,
});
setTimeoutRef = Meteor.setTimeout(() => {
authTokenValidationObserver.stop();
rej();
}, AUTH_TIMEOUT);
try {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'ValidateAuthTokenReqMsg';
Logger.debug('ValidateAuthToken method called', { meetingId, requesterUserId, requesterToken, externalId });
if (!meetingId) return false;
// Store reference of methodInvocationObject ( to postpone the connection userId definition )
pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this);
await upsertValidationState(
meetingId,
requesterUserId,
ValidationStates.VALIDATING,
this.connection.id,
);
const payload = {
userId: requesterUserId,
authToken: requesterToken,
};
Logger.info(`User '${requesterUserId}' is trying to validate auth token for meeting '${meetingId}' from connection '${this.connection.id}'`);
return RedisPubSub.publishUserMessage(
CHANNEL,
EVENT_NAME,
meetingId,
requesterUserId,
payload,
);
} catch (err) {
const errMsg = `Exception while invoking method validateAuthToken ${err}`;
Logger.error(errMsg);
rej(errMsg);
Meteor.clearTimeout(setTimeoutRef);
authTokenValidationObserver.stop();
}
});
return userValidation;
}
export default validateAuthToken;

View File

@ -1,7 +1,6 @@
import Logger from './logger';
import userLeaving from '/imports/api/users/server/methods/userLeaving';
import { extractCredentials } from '/imports/api/common/server/helpers';
import AuthTokenValidation from '/imports/api/auth-token-validation';
import Users from '/imports/api/users';
import { check } from 'meteor/check';
@ -140,13 +139,6 @@ class ClientConnections {
Logger.debug(`Found ${activeConnections.length} active connections in server`);
const onlineUsers = AuthTokenValidation
.find(
{ connectionId: { $in: activeConnections } },
{ fields: { meetingId: 1, userId: 1 } }
)
.fetch();
const onlineUsersId = onlineUsers.map(({ userId }) => userId);
const usersQuery = { userId: { $nin: onlineUsersId } };

View File

@ -25,7 +25,10 @@ export interface Public {
clientLog: ClientLog
virtualBackgrounds: VirtualBackgrounds
}
export interface Locales {
locale: string
name: string
}
export interface App {
instanceId: string
mobileFontSize: string
@ -453,6 +456,7 @@ export interface Captions {
font: Font
lines: number
time: number
locales: Locales[]
}
export interface Font {

View File

@ -49,6 +49,29 @@ export interface Reaction {
reactionEmoji: string;
}
export interface BreakoutRooms {
currentRoomJoined: boolean;
assignedAt: string;
breakoutRoomId: string;
currentRoomIsOnline: boolean | null;
currentRoomPriority: number;
currentRoomRegisteredAt: string | null;
durationInSeconds: number;
endedAt: string | null;
freeJoin: boolean;
inviteDismissedAt: string | null;
isDefaultName: boolean;
joinURL: string;
lastRoomIsOnline: boolean;
lastRoomJoinedAt: string;
lastRoomJoinedId: string;
name: string;
sendInvitationToModerators: boolean;
sequence: number;
shortName: string;
showInvitation: boolean;
startedAt: string;
}
export interface UserClientSettings {
userClientSettingsJson: string;
}
@ -87,8 +110,8 @@ export interface User {
isDialIn: boolean;
voice?: Partial<Voice>;
locked: boolean;
registeredAt: number;
registeredOn: string;
registeredAt: string;
registeredOn: number;
hasDrawPermissionOnCurrentPage: boolean;
lastBreakoutRoom?: LastBreakoutRoom;
cameras: Array<Cameras>;
@ -99,6 +122,7 @@ export interface User {
away: boolean;
raiseHand: boolean;
reaction: Reaction;
breakoutRooms: BreakoutRooms;
customParameters: Array<CustomParameter>;
userClientSettings: UserClientSettings;
}

View File

@ -1,5 +1,4 @@
import React, { PureComponent } from 'react';
import deviceInfo from '/imports/utils/deviceInfo';
import { ActionsBarItemType, ActionsBarPosition } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/actions-bar-item/enums';
import Styled from './styles';
import ActionsDropdown from './actions-dropdown/container';
@ -111,6 +110,7 @@ class ActionsBar extends PureComponent {
showPushLayout,
setPushLayout,
setPresentationFitToWidth,
} = this.props;
const { selectedLayout } = Settings.application;
@ -151,11 +151,8 @@ class ActionsBar extends PureComponent {
setPresentationFitToWidth,
}}
/>
{!deviceInfo.isMobile
? (
<AudioCaptionsButtonContainer />
)
: null}
</Styled.Left>
<Styled.Center>
{this.renderPluginsActionBarItems(ActionsBarPosition.LEFT)}

View File

@ -30,6 +30,7 @@ const ActionsBarContainer = (props) => {
const { data: currentMeeting } = useMeeting((m) => ({
externalVideo: m.externalVideo,
componentsFlags: m.componentsFlags,
}));
const isSharingVideo = !!currentMeeting?.externalVideo?.externalVideoUrl;
@ -56,6 +57,7 @@ const ActionsBarContainer = (props) => {
const amIModerator = currentUserData?.isModerator;
if (actionsBarStyle.display === false) return null;
if (!currentMeeting) return null;
return (
<ActionsBar {
@ -70,14 +72,17 @@ const ActionsBarContainer = (props) => {
isThereCurrentPresentation,
isSharingVideo,
stopExternalVideoShare,
isCaptionsAvailable: currentMeeting.componentsFlags.hasCaption,
}
}
/>
);
};
const RAISE_HAND_BUTTON_ENABLED = window.meetingClientSettings.public.app.raiseHandActionButton.enabled;
const RAISE_HAND_BUTTON_CENTERED = window.meetingClientSettings.public.app.raiseHandActionButton.centered;
const RAISE_HAND_BUTTON_ENABLED = window.meetingClientSettings
.public.app.raiseHandActionButton.enabled;
const RAISE_HAND_BUTTON_CENTERED = window.meetingClientSettings
.public.app.raiseHandActionButton.centered;
const isReactionsButtonEnabled = () => {
const USER_REACTIONS_ENABLED = window.meetingClientSettings.public.userReaction.enabled;

View File

@ -30,7 +30,8 @@ const TimeSync: React.FC = () => {
useEffect(() => {
if (!loading && data) {
const time = new Date(data.current_time[0].currentTimestamp);
setTimeSync(time.getTime() - new Date().getTime());
const dateNow = new Date();
setTimeSync(time.getTime() - dateNow.getTime());
}
}, [data, loading]);
return null;

View File

@ -18,7 +18,7 @@ import AudioContainer from '../audio/container';
import BannerBarContainer from '/imports/ui/components/banner-bar/container';
import RaiseHandNotifier from '/imports/ui/components/raisehand-notifier/container';
import ManyWebcamsNotifier from '/imports/ui/components/video-provider/many-users-notify/container';
import AudioCaptionsSpeechContainer from '/imports/ui/components/audio/captions/speech/container';
import AudioCaptionsSpeechContainer from '/imports/ui/components/audio/audio-graphql/audio-captions/speech/component';
import UploaderContainer from '/imports/ui/components/presentation/presentation-uploader/container';
import ScreenReaderAlertContainer from '../screenreader-alert/container';
import ScreenReaderAlertAdapter from '../screenreader-alert/adapter';
@ -40,7 +40,7 @@ import SidebarContentContainer from '../sidebar-content/container';
import PluginsEngineManager from '../plugins-engine/manager';
import Settings from '/imports/ui/services/settings';
import { registerTitleView } from '/imports/utils/dom-utils';
import Notifications from '../notifications/container';
import Notifications from '../notifications/component';
import GlobalStyles from '/imports/ui/stylesheets/styled-components/globalStyles';
import ActionsBarContainer from '../actions-bar/container';
import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine';
@ -51,6 +51,7 @@ import AppService from '/imports/ui/components/app/service';
import TimerService from '/imports/ui/components/timer/service';
import TimeSync from './app-graphql/time-sync/component';
import PresentationUploaderToastContainer from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/container';
import BreakoutJoinConfirmationContainerGraphQL from '../breakout-join-confirmation/breakout-join-confirmation-graphql/component';
import FloatingWindowContainer from '/imports/ui/components/floating-window/container';
import ChatAlertContainerGraphql from '../chat/chat-graphql/alert/component';
@ -565,6 +566,7 @@ setRandomUserSelectModalIsOpen(value) {
intl,
isModerator,
genericComponentId,
speechLocale,
} = this.props;
const {
@ -635,13 +637,15 @@ setRandomUserSelectModalIsOpen(value) {
{this.renderAudioCaptions()}
<PresentationUploaderToastContainer intl={intl} />
<UploaderContainer />
<BreakoutRoomInvitation isModerator={isModerator} />
<BreakoutJoinConfirmationContainerGraphQL />
<AudioContainer {...{
isAudioModalOpen,
setAudioModalIsOpen: this.setAudioModalIsOpen,
isVideoPreviewModalOpen,
setVideoPreviewModalIsOpen: this.setVideoPreviewModalIsOpen,
}} />
speechLocale,
}}
/>
<ToastContainer rtl />
{(audioAlertEnabled || pushAlertEnabled)
&& (

View File

@ -102,6 +102,7 @@ const AppContainer = (props) => {
enforceLayout: user.enforceLayout,
isModerator: user.isModerator,
presenter: user.presenter,
speechLocale: user.speechLocale,
}));
const isModerator = currentUserData?.isModerator;
@ -185,6 +186,9 @@ const AppContainer = (props) => {
const shouldShowPresentation = (!shouldShowScreenshare && !isSharedNotesPinned
&& !shouldShowExternalVideo && !shouldShowGenericComponent
&& (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled();
if (!currentUserData) return null;
return currentUserId
? (
<App
@ -222,6 +226,7 @@ const AppContainer = (props) => {
isPresenter,
numCameras: cameraDockInput.numCameras,
enforceLayout: validateEnforceLayout(currentUserData),
speechLocale: currentUserData?.speechLocale,
isModerator,
shouldShowScreenshare,
isSharedNotesPinned,
@ -230,6 +235,7 @@ const AppContainer = (props) => {
toggleVoice,
setLocalSettings,
genericComponentId: genericComponent.genericComponentId,
audioCaptions: <AudioCaptionsLiveContainer speechLocale={currentUserData?.speechLocale} />,
}}
{...otherProps}
/>

View File

@ -4,16 +4,21 @@ import { Layout } from '/imports/ui/components/layout/layoutTypes';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
import BBBMenu from '/imports/ui/components/common/menu/component';
import { defineMessages, useIntl } from 'react-intl';
import { useMutation, useSubscription } from '@apollo/client';
import Styled from './styles';
import {
getSpeechVoices, isAudioTranscriptionEnabled, setAudioCaptions, setSpeechLocale,
setAudioCaptions, setSpeechLocale,
} from '../service';
import { defineMessages, useIntl } from 'react-intl';
import { MenuSeparatorItemType, MenuOptionItemType } from '/imports/ui/components/common/menu/menuTypes';
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
import { User } from '/imports/ui/Types/user';
import { useMutation } from '@apollo/client';
import { SET_SPEECH_LOCALE } from '/imports/ui/core/graphql/mutations/userMutations';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import { ActiveCaptionsResponse, getactiveCaptions } from './queries';
const CONFIG = window.meetingClientSettings.public.app.audioCaptions;
const PROVIDER = CONFIG.provider;
const intlMessages = defineMessages({
start: {
@ -89,7 +94,6 @@ interface AudioCaptionsButtonProps {
availableVoices: string[];
currentSpeechLocale: string;
isSupported: boolean;
isVoiceUser: boolean;
}
const DISABLED = '';
@ -99,8 +103,8 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
currentSpeechLocale,
availableVoices,
isSupported,
isVoiceUser,
}) => {
const knownLocales = window.meetingClientSettings.public.captions.locales;
const intl = useIntl();
const [active] = useAudioCaptionEnable();
const [setSpeechLocaleMutation] = useMutation(SET_SPEECH_LOCALE);
@ -127,11 +131,8 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
}, [currentSpeechLocale]);
const shouldRenderChevron = isSupported && isVoiceUser;
const toggleTranscription = () => {
setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED, setUserSpeechLocale);
};
const shouldRenderChevron = isSupported;
const shouldRenderSelector = isSupported && availableVoices.length > 0;
const getAvailableLocales = () => {
let indexToInsertSeparator = -1;
@ -166,8 +167,27 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
];
};
const getAvailableLocalesList = () => (
[{
const getAvailableCaptions = () => {
return availableVoices.map((caption) => {
const localeName = knownLocales ? knownLocales.find((l) => l.locale === caption)?.name : 'en';
return {
key: caption,
label: localeName,
customStyles: (selectedLocale.current === caption) && Styled.SelectedLabel,
iconRight: selectedLocale.current === caption ? 'check' : null,
onClick: () => {
selectedLocale.current = caption;
setSpeechLocale(selectedLocale.current, setUserSpeechLocale);
},
};
});
};
const getAvailableLocalesList = () => {
// audio captions
if (shouldRenderChevron) {
return [{
key: 'availableLocalesList',
label: intl.formatMessage(intlMessages.language),
customStyles: Styled.TitleLabel,
@ -183,22 +203,24 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
{
key: 'separator-02',
isSeparator: true,
}];
}
// typed captions
return [{
key: 'availableLocalesList',
label: intl.formatMessage(intlMessages.language),
customStyles: Styled.TitleLabel,
disabled: true,
},
{
key: 'transcriptionStatus',
label: intl.formatMessage(
isTranscriptionDisabled()
? intlMessages.transcriptionOn
: intlMessages.transcriptionOff,
),
customStyles: isTranscriptionDisabled()
? Styled.EnableTrascription : Styled.DisableTrascription,
disabled: false,
onClick: toggleTranscription,
}]
);
...getAvailableCaptions(),
];
};
const onToggleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!currentSpeechLocale && !active) {
setUserSpeechLocale(availableVoices[0], PROVIDER);
}
setAudioCaptions(!active);
};
@ -216,7 +238,7 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
);
return (
shouldRenderChevron
shouldRenderChevron || shouldRenderSelector
? (
<Styled.SpanButtonWrapper>
<BBBMenu
@ -261,15 +283,30 @@ const AudioCaptionsButtonContainer: React.FC = () => {
}),
);
if (currentUserLoading) return null;
if (!currentUser) return null;
const {
data: currentMeetingData,
loading: currentMeetingLoading,
} = useMeeting((m) => ({
componentsFlags: m.componentsFlags,
}));
const availableVoices = getSpeechVoices();
const {
data: activeCaptionsData,
loading: activeCaptionsLoading,
} = useSubscription<ActiveCaptionsResponse>(getactiveCaptions);
if (currentUserLoading) return null;
if (currentMeetingLoading) return null;
if (activeCaptionsLoading) return null;
if (!currentUser) return null;
if (!currentMeetingData) return null;
if (!activeCaptionsData) return null;
const availableVoices = activeCaptionsData.caption_activeLocales.map((caption) => caption.locale);
const currentSpeechLocale = currentUser.speechLocale || '';
const isSupported = availableVoices.length > 0;
const isVoiceUser = !!currentUser.voice;
if (!isAudioTranscriptionEnabled()) return null;
if (!currentMeetingData.componentsFlags?.hasCaption) return null;
return (
<AudioCaptionsButton
@ -277,7 +314,6 @@ const AudioCaptionsButtonContainer: React.FC = () => {
availableVoices={availableVoices}
currentSpeechLocale={currentSpeechLocale}
isSupported={isSupported}
isVoiceUser={isVoiceUser}
/>
);
};

View File

@ -8,6 +8,20 @@ export interface GetAudioCaptionsCountResponse {
};
}
export interface ActiveCaptionsResponse {
caption_activeLocales: Array<{
locale: string;
}>;
}
export const getactiveCaptions = gql`
subscription activeCaptions {
caption_activeLocales {
locale
}
}
`;
export const GET_AUDIO_CAPTIONS_COUNT = gql`
subscription GetAudioCaptionsCount {
caption_aggregate {
@ -20,4 +34,5 @@ export const GET_AUDIO_CAPTIONS_COUNT = gql`
export default {
GET_AUDIO_CAPTIONS_COUNT,
getactiveCaptions,
};

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useMutation } from '@apollo/client';
@ -144,6 +144,14 @@ const AudioCaptionsSelect: React.FC<AudioCaptionsSelectProps> = ({
};
const AudioCaptionsSelectContainer: React.FC = () => {
const [voicesList, setVoicesList] = React.useState<string[]>([]);
const voices = getSpeechVoices();
useEffect(() => {
if (voices && voicesList.length === 0) {
setVoicesList(voices);
}
}, [voices]);
const {
data: currentUser,
} = useCurrentUser(
@ -153,15 +161,13 @@ const AudioCaptionsSelectContainer: React.FC = () => {
}),
);
const isEnabled = isAudioTranscriptionEnabled();
const voices = getSpeechVoices();
if (!currentUser || !isEnabled || !voices) return null;
return (
<AudioCaptionsSelect
isTranscriptionEnabled={isEnabled}
speechLocale={currentUser.speechLocale ?? ''}
speechVoices={voices}
speechVoices={voices || voicesList}
/>
);
};

View File

@ -5,6 +5,7 @@ import logger from '/imports/startup/client/logger';
import Styled from './styles';
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
interface AudioCaptionsLiveProps {
captions: Caption[];
@ -54,11 +55,19 @@ const AudioCaptionsLive: React.FC<AudioCaptionsLiveProps> = ({
};
const AudioCaptionsLiveContainer: React.FC = () => {
const {
data: currentUser,
} = useCurrentUser((u) => ({
speechLocale: u.speechLocale,
}));
const {
data: AudioCaptionsLiveData,
loading: AudioCaptionsLiveLoading,
error: AudioCaptionsLiveError,
} = useSubscription<getCaptions>(GET_CAPTIONS);
} = useSubscription<getCaptions>(GET_CAPTIONS, {
variables: { locale: currentUser?.speechLocale ?? 'en-US' },
});
const [audioCaptionsEnable] = useAudioCaptionEnable();

View File

@ -26,8 +26,8 @@ export interface GetAudioCaptions {
}
export const GET_CAPTIONS = gql`
subscription getCaptions {
caption {
subscription getCaptions($locale: String!) {
caption(where: {locale: {_eq: $locale}}) {
user {
avatar
color

View File

@ -1,5 +1,4 @@
import { unique } from 'radash';
import logger from '/imports/startup/client/logger';
import { setAudioCaptionEnable } from '/imports/ui/core/local-states/useAudioCaptionEnable';
import { isLiveTranscriptionEnabled } from '/imports/ui/services/features';
@ -30,15 +29,7 @@ export const setAudioCaptions = (value: boolean) => {
};
export const setSpeechLocale = (value: string, setUserSpeechLocale: (a: string, b: string) => void) => {
const voices = getSpeechVoices();
if (voices.includes(value) || value === '') {
setUserSpeechLocale(value, CONFIG.provider);
} else {
logger.error({
logCode: 'captions_speech_locale',
}, 'Captions speech set locale error');
}
};
export const useFixedLocale = () => isAudioTranscriptionEnabled() && CONFIG.language.forceLocale;

View File

@ -12,8 +12,8 @@ import Help from '../help/component';
import AudioDial from '../audio-dial/component';
import AudioAutoplayPrompt from '../autoplay/component';
import Settings from '/imports/ui/services/settings';
import CaptionsSelectContainer from '/imports/ui/components/audio/captions/select/container';
import usePreviousValue from '/imports/ui/hooks/usePreviousValue';
import AudioCaptionsSelectContainer from '../audio-graphql/audio-captions/captions/component';
const propTypes = {
intl: PropTypes.shape({
@ -351,7 +351,7 @@ const AudioModal = (props) => {
}}
/>
) : null}
<CaptionsSelectContainer />
<AudioCaptionsSelectContainer />
</div>
);
};

View File

@ -1,104 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import UserContainer from './user/container';
const CAPTIONS_CONFIG = window.meetingClientSettings.public.captions;
class LiveCaptions extends PureComponent {
constructor(props) {
super(props);
this.state = { clear: true };
this.timer = null;
}
componentDidUpdate(prevProps) {
const { clear } = this.state;
if (clear) {
const { transcript } = this.props;
if (prevProps.transcript !== transcript) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ clear: false });
}
} else {
this.resetTimer();
this.timer = setTimeout(() => this.setState({ clear: true }), CAPTIONS_CONFIG.time);
}
}
componentWillUnmount() {
this.resetTimer();
}
resetTimer() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
render() {
const {
transcript,
transcriptId,
} = this.props;
const { clear } = this.state;
const hasContent = transcript.length > 0 && !clear;
const wrapperStyles = {
display: 'flex',
};
const captionStyles = {
whiteSpace: 'pre-line',
wordWrap: 'break-word',
fontFamily: 'Verdana, Arial, Helvetica, sans-serif',
fontSize: '1.5rem',
background: '#000000a0',
color: 'white',
padding: hasContent ? '.5rem' : undefined,
};
const visuallyHidden = {
position: 'absolute',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
height: '1px',
width: '1px',
margin: '-1px',
padding: '0',
border: '0',
};
return (
<div style={wrapperStyles}>
{clear ? null : (
<UserContainer
background="#000000a0"
transcriptId={transcriptId}
/>
)}
<div style={captionStyles}>
{clear ? '' : transcript}
</div>
<div
style={visuallyHidden}
aria-atomic
aria-live="polite"
>
{clear ? '' : transcript}
</div>
</div>
);
}
}
LiveCaptions.propTypes = {
transcript: PropTypes.string.isRequired,
transcriptId: PropTypes.string.isRequired,
};
export default LiveCaptions;

View File

@ -1,44 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Users from '/imports/api/users';
import User from './component';
const MODERATOR = window.meetingClientSettings.public.user.role_moderator;
const Container = (props) => <User {...props} />;
const getUser = (userId) => {
const user = Users.findOne(
{ userId },
{
fields: {
avatar: 1,
color: 1,
role: 1,
name: 1,
},
},
);
if (user) {
return {
avatar: user.avatar,
color: user.color,
moderator: user.role === MODERATOR,
name: user.name,
};
}
return {
avatar: '',
color: '',
moderator: false,
name: '',
};
};
export default withTracker(({ transcriptId }) => {
const userId = transcriptId.split('-')[0];
return getUser(userId);
})(Container);

View File

@ -1,142 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import SpeechService from '/imports/ui/components/audio/captions/speech/service';
import { useMutation } from '@apollo/client';
import { SET_SPEECH_LOCALE } from '/imports/ui/core/graphql/mutations/userMutations';
const intlMessages = defineMessages({
title: {
id: 'app.audio.captions.speech.title',
description: 'Audio speech recognition title',
},
disabled: {
id: 'app.audio.captions.speech.disabled',
description: 'Audio speech recognition disabled',
},
unsupported: {
id: 'app.audio.captions.speech.unsupported',
description: 'Audio speech recognition unsupported',
},
'de-DE': {
id: 'app.audio.captions.select.de-DE',
description: 'Audio speech recognition german language',
},
'en-US': {
id: 'app.audio.captions.select.en-US',
description: 'Audio speech recognition english language',
},
'es-ES': {
id: 'app.audio.captions.select.es-ES',
description: 'Audio speech recognition spanish language',
},
'fr-FR': {
id: 'app.audio.captions.select.fr-FR',
description: 'Audio speech recognition french language',
},
'hi-ID': {
id: 'app.audio.captions.select.hi-ID',
description: 'Audio speech recognition indian language',
},
'it-IT': {
id: 'app.audio.captions.select.it-IT',
description: 'Audio speech recognition italian language',
},
'ja-JP': {
id: 'app.audio.captions.select.ja-JP',
description: 'Audio speech recognition japanese language',
},
'pt-BR': {
id: 'app.audio.captions.select.pt-BR',
description: 'Audio speech recognition portuguese language',
},
'ru-RU': {
id: 'app.audio.captions.select.ru-RU',
description: 'Audio speech recognition russian language',
},
'zh-CN': {
id: 'app.audio.captions.select.zh-CN',
description: 'Audio speech recognition chinese language',
},
});
const Select = ({
intl,
enabled,
locale,
voices,
}) => {
const useLocaleHook = SpeechService.useFixedLocale();
const [setSpeechLocale] = useMutation(SET_SPEECH_LOCALE);
const setUserSpeechLocale = (speechLocale, provider) => {
setSpeechLocale({
variables: {
locale: speechLocale,
provider,
},
});
};
if (!enabled || useLocaleHook) return null;
if (voices.length === 0) {
return (
<div data-test="speechRecognitionUnsupported"
style={{
fontSize: '.75rem',
padding: '1rem 0',
}}
>
{`*${intl.formatMessage(intlMessages.unsupported)}`}
</div>
);
}
const onChange = (e) => {
const { value } = e.target;
SpeechService.setSpeechLocale(value, setUserSpeechLocale);
};
return (
<div style={{ padding: '1rem 0' }}>
<label
htmlFor="speechSelect"
style={{ padding: '0 .5rem' }}
>
{intl.formatMessage(intlMessages.title)}
</label>
<select
id="speechSelect"
onChange={onChange}
value={locale}
>
<option
key="disabled"
value=""
>
{intl.formatMessage(intlMessages.disabled)}
</option>
{voices.map((v) => (
<option
key={v}
value={v}
>
{intl.formatMessage(intlMessages[v])}
</option>
))}
</select>
</div>
);
};
Select.propTypes = {
enabled: PropTypes.bool.isRequired,
locale: PropTypes.string.isRequired,
voices: PropTypes.arrayOf(PropTypes.string).isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
};
export default injectIntl(Select);

View File

@ -1,15 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Service from '/imports/ui/components/audio/captions/speech/service';
import Select from './component';
import AudioCaptionsSelectContainer from '../../audio-graphql/audio-captions/captions/component';
const Container = (props) => <Select {...props} />;
withTracker(() => ({
enabled: Service.isEnabled(),
locale: Service.getSpeechLocale(),
voices: Service.getSpeechVoices(),
}))(Container);
export default AudioCaptionsSelectContainer;

View File

@ -1,8 +0,0 @@
const getAudioCaptions = () => Session.get('audioCaptions') || false;
const setAudioCaptions = (value) => Session.set('audioCaptions', value);
export default {
getAudioCaptions,
setAudioCaptions,
};

View File

@ -1,160 +0,0 @@
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import logger from '/imports/startup/client/logger';
import { throttle } from 'radash';
import Service from './service';
const THROTTLE_TIMEOUT = 200;
class Speech extends PureComponent {
constructor(props) {
super(props);
this.onEnd = this.onEnd.bind(this);
this.onError = this.onError.bind(this);
this.onResult = this.onResult.bind(this);
this.result = {
id: Service.generateId(),
transcript: '',
isFinal: true,
};
this.idle = true;
this.speechRecognition = Service.initSpeechRecognition(props.setUserSpeechLocale);
if (this.speechRecognition) {
this.speechRecognition.onend = () => this.onEnd();
this.speechRecognition.onerror = (event) => this.onError(event);
this.speechRecognition.onresult = (event) => this.onResult(event);
}
this.throttledTranscriptUpdate = throttle(
{ interval: THROTTLE_TIMEOUT },
props.captionSubmitText
);
}
componentDidUpdate(prevProps) {
const {
locale,
connected,
talking,
} = this.props;
// Connected
if (!prevProps.connected && connected) {
this.start(locale);
}
// Disconnected
if (prevProps.connected && !connected) {
this.stop();
}
// Switch locale
if (prevProps.locale !== locale) {
if (prevProps.connected && connected) {
this.stop();
this.start(locale);
}
}
// Recovery from idle
if (!prevProps.talking && talking) {
if (prevProps.connected && connected) {
if (this.idle) {
this.start(locale);
}
}
}
}
componentWillUnmount() {
this.stop();
}
onEnd() {
this.stop();
}
onError(event) {
this.stop();
logger.error({
logCode: 'captions_speech_recognition',
extraInfo: {
error: event.error,
message: event.message,
},
}, 'Captions speech recognition error');
}
onResult(event) {
const {
resultIndex,
results,
} = event;
const { id } = this.result;
const { transcript } = results[resultIndex][0];
const { isFinal } = results[resultIndex];
this.result.transcript = transcript;
this.result.isFinal = isFinal;
const { locale, captionSubmitText } = this.props;
if (isFinal) {
captionSubmitText(id, transcript, locale, true);
this.result.id = Service.generateId();
} else {
this.throttledTranscriptUpdate(id, transcript, locale, false);
}
}
start(locale) {
if (this.speechRecognition && Service.isLocaleValid(locale)) {
this.speechRecognition.lang = locale;
try {
this.result.id = Service.generateId();
this.speechRecognition.start();
this.idle = false;
} catch (event) {
this.onError(event);
}
}
}
stop() {
this.idle = true;
if (this.speechRecognition) {
const {
isFinal,
transcript,
} = this.result;
if (!isFinal) {
const { locale } = this.props;
const { id } = this.result;
Service.updateFinalTranscript(id, transcript, locale);
this.speechRecognition.abort();
} else {
this.speechRecognition.stop();
}
}
}
render() {
return null;
}
}
Speech.propTypes = {
locale: PropTypes.string.isRequired,
connected: PropTypes.bool.isRequired,
talking: PropTypes.bool.isRequired,
setUserSpeechLocale: PropTypes.func.isRequired,
};
export default Speech;

View File

@ -1,84 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { useMutation } from '@apollo/client';
import { diff } from '@mconf/bbb-diff';
import Service from './service';
import Speech from './component';
import AudioCaptionsSpeechContainer from '../../audio-graphql/audio-captions/speech/component';
import { SET_SPEECH_LOCALE } from '/imports/ui/core/graphql/mutations/userMutations';
import { SUBMIT_TEXT } from './mutations';
let prevId = '';
let prevTranscript = '';
const Container = (props) => {
const [setSpeechLocale] = useMutation(SET_SPEECH_LOCALE);
const [submitText] = useMutation(SUBMIT_TEXT);
const setUserSpeechLocale = (locale, provider) => {
setSpeechLocale({
variables: {
locale,
provider,
},
});
};
const captionSubmitText = (id, transcript, locale, isFinal) => {
// If it's a new sentence
if (id !== prevId) {
prevId = id;
prevTranscript = '';
}
const transcriptDiff = diff(prevTranscript, transcript);
let start = 0;
let end = 0;
let text = '';
if (transcriptDiff) {
start = transcriptDiff.start;
end = transcriptDiff.end;
text = transcriptDiff.text;
}
// Stores current transcript as previous
prevTranscript = transcript;
submitText({
variables: {
transcriptId: id,
start,
end,
text,
transcript,
locale,
isFinal,
},
});
};
return (
<Speech
setUserSpeechLocale={setUserSpeechLocale}
captionSubmitText={captionSubmitText}
{...props}
/>
);
};
withTracker(() => {
const {
locale,
connected,
talking,
} = Service.getStatus();
return {
locale,
connected,
talking,
};
})(Container);
export default AudioCaptionsSpeechContainer;

View File

@ -1,27 +0,0 @@
import { gql } from '@apollo/client';
export const SUBMIT_TEXT = gql`
mutation SubmitText(
$transcriptId: String!
$start: Int!
$end: Int!
$text: String!
$transcript: String!
$locale: String!
$isFinal: Boolean!
) {
captionSubmitText(
transcriptId: $transcriptId,
start: $start,
end: $end,
text: $text,
transcript: $transcript,
locale: $locale,
isFinal: $isFinal,
)
}
`;
export default {
SUBMIT_TEXT,
};

View File

@ -1,143 +0,0 @@
import { Session } from 'meteor/session';
import Auth from '/imports/ui/services/auth';
import logger from '/imports/startup/client/logger';
import Users from '/imports/api/users';
import AudioService from '/imports/ui/components/audio/service';
import deviceInfo from '/imports/utils/deviceInfo';
import { isLiveTranscriptionEnabled } from '/imports/ui/services/features';
import { unique } from 'radash';
const CONFIG = window.meetingClientSettings.public.app.audioCaptions;
const ENABLED = CONFIG.enabled;
const PROVIDER = CONFIG.provider;
const LANGUAGES = CONFIG.language.available;
const VALID_ENVIRONMENT = !deviceInfo.isMobile || CONFIG.mobile;
const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
const hasSpeechRecognitionSupport = () => typeof SpeechRecognitionAPI !== 'undefined'
&& typeof window.speechSynthesis !== 'undefined'
&& VALID_ENVIRONMENT;
const setSpeechVoices = () => {
if (!hasSpeechRecognitionSupport()) return;
Session.set('speechVoices', unique(window.speechSynthesis.getVoices().map((v) => v.lang)));
};
// Trigger getVoices
setSpeechVoices();
const getSpeechVoices = () => {
if (!isWebSpeechApi()) return LANGUAGES;
const voices = Session.get('speechVoices') || [];
return voices.filter((v) => LANGUAGES.includes(v));
};
const setSpeechLocale = (value, setUserSpeechLocale) => {
const voices = getSpeechVoices();
if (voices.includes(value) || value === '') {
setUserSpeechLocale(value, CONFIG.provider);
} else {
logger.error({
logCode: 'captions_speech_locale',
}, 'Captions speech set locale error');
}
};
const useFixedLocale = () => isEnabled() && CONFIG.language.forceLocale;
const initSpeechRecognition = (setUserSpeechLocale) => {
if (!isEnabled() || !isWebSpeechApi()) return null;
if (hasSpeechRecognitionSupport()) {
// Effectivate getVoices
setSpeechVoices();
const speechRecognition = new SpeechRecognitionAPI();
speechRecognition.continuous = true;
speechRecognition.interimResults = true;
if (useFixedLocale() || localeAsDefaultSelected()) {
setSpeechLocale(getLocale(), setUserSpeechLocale);
} else {
setSpeechLocale(navigator.language, setUserSpeechLocale);
}
return speechRecognition;
}
logger.warn({
logCode: 'captions_speech_unsupported',
}, 'Captions speech unsupported');
return null;
};
const getSpeechLocale = (userId = Auth.userID) => {
const user = Users.findOne({ userId }, { fields: { speechLocale: 1 } });
if (user) return user.speechLocale;
return '';
};
const hasSpeechLocale = (userId = Auth.userID) => getSpeechLocale(userId) !== '';
const isLocaleValid = (locale) => LANGUAGES.includes(locale);
const isEnabled = () => isLiveTranscriptionEnabled();
const isWebSpeechApi = () => PROVIDER === 'webspeech';
const isVosk = () => PROVIDER === 'vosk';
const isWhispering = () => PROVIDER === 'whisper';
const isDeepSpeech = () => PROVIDER === 'deepSpeech'
const isActive = () => isEnabled() && ((isWebSpeechApi() && hasSpeechLocale()) || isVosk() || isWhispering() || isDeepSpeech());
const getStatus = () => {
const active = isActive();
const locale = getSpeechLocale();
const audio = AudioService.isConnected() && !AudioService.isEchoTest() && !AudioService.isMuted();
const connected = Meteor.status().connected && active && audio;
const talking = AudioService.isTalking();
return {
locale,
connected,
talking,
};
};
const generateId = () => `${Auth.userID}-${Date.now()}`;
const localeAsDefaultSelected = () => CONFIG.language.defaultSelectLocale;
const getLocale = () => {
const { locale } = CONFIG.language;
if (locale === 'browserLanguage') return navigator.language;
if (locale === 'disabled') return '';
return locale;
};
const stereoUnsupported = () => isActive() && isVosk() && !!getSpeechLocale();
export default {
LANGUAGES,
hasSpeechRecognitionSupport,
initSpeechRecognition,
getSpeechVoices,
getSpeechLocale,
setSpeechLocale,
hasSpeechLocale,
isLocaleValid,
isEnabled,
isActive,
getStatus,
generateId,
useFixedLocale,
stereoUnsupported,
};

View File

@ -185,6 +185,7 @@ const messages = {
export default lockContextContainer(injectIntl(withTracker(({
intl, userLocks, isAudioModalOpen, setAudioModalIsOpen, setVideoPreviewModalIsOpen,
speechLocale,
}) => {
const { microphoneConstraints } = Settings.application;
const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
@ -239,7 +240,7 @@ export default lockContextContainer(injectIntl(withTracker(({
setAudioModalIsOpen,
microphoneConstraints,
init: async (toggleVoice) => {
await Service.init(messages, intl, toggleVoice);
await Service.init(messages, intl, toggleVoice, speechLocale);
if ((!autoJoin || didMountAutoJoin)) {
if (enableVideo && autoShareWebcam) {
openVideoPreviewModal();

View File

@ -45,7 +45,7 @@ const audioEventHandler = (toggleVoice) => (event) => {
}
};
const init = (messages, intl, toggleVoice) => {
const init = (messages, intl, toggleVoice, speechLocale) => {
AudioManager.setAudioMessages(messages, intl);
if (AudioManager.initialized) return Promise.resolve(false);
const meetingId = Auth.meetingID;
@ -66,6 +66,7 @@ const init = (messages, intl, toggleVoice) => {
username,
voiceBridge,
microphoneLockEnforced,
speechLocale,
};
return AudioManager.init(userData, audioEventHandler(toggleVoice));

View File

@ -1,99 +0,0 @@
import React, { Component } from 'react';
import { Session } from 'meteor/session';
import logger from '/imports/startup/client/logger';
import Auth from '/imports/ui/services/auth';
import LoadingScreen from '/imports/ui/components/common/loading-screen/component';
const STATUS_CONNECTING = 'connecting';
class AuthenticatedHandler extends Component {
static setError({ description, error }) {
if (error) Session.set('codeError', error);
Session.set('errorMessageDescription', description);
}
static shouldAuthenticate(status, lastStatus) {
return lastStatus != null && lastStatus === STATUS_CONNECTING && status.connected;
}
static updateStatus(status, lastStatus) {
return lastStatus !== STATUS_CONNECTING ? status.status : lastStatus;
}
static addReconnectObservable() {
let lastStatus = null;
Tracker.autorun(() => {
lastStatus = AuthenticatedHandler.updateStatus(Meteor.status(), lastStatus);
if (AuthenticatedHandler.shouldAuthenticate(Meteor.status(), lastStatus)) {
Session.set('userWillAuth', true);
Auth.authenticate(true);
lastStatus = Meteor.status().status;
}
});
}
static async authenticatedRouteHandler(callback) {
if (Auth.loggedIn) {
callback();
}
AuthenticatedHandler.addReconnectObservable();
const setReason = (reason) => {
const log = reason.error === 403 ? 'warn' : 'error';
logger[log]({
logCode: 'authenticatedhandlercomponent_setreason',
extraInfo: { reason },
}, 'Encountered error while trying to authenticate');
AuthenticatedHandler.setError(reason);
callback();
};
try {
const getAuthenticate = await Auth.authenticate();
callback(getAuthenticate);
} catch (error) {
setReason(error);
}
}
constructor(props) {
super(props);
this.state = {
authenticated: false,
};
}
componentDidMount() {
if (Session.get('codeError')) {
console.log('Session.get(codeError)', Session.get('codeError'));
this.setState({ authenticated: true });
}
AuthenticatedHandler.authenticatedRouteHandler((value, error) => {
if (error) AuthenticatedHandler.setError(error);
this.setState({ authenticated: true });
});
}
render() {
const {
children,
} = this.props;
const {
authenticated,
} = this.state;
Session.set('isMeetingEnded', false);
Session.set('isPollOpen', false);
// TODO: breakoutRoomIsOpen doesn't seem used
Session.set('breakoutRoomIsOpen', false);
return authenticated
? children
: (<LoadingScreen />);
}
}
export default AuthenticatedHandler;

View File

@ -0,0 +1,201 @@
import { useMutation, useSubscription } from '@apollo/client';
import React, { useEffect, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Styled from './styles';
import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
import {
BreakoutRoom,
getBreakoutCount,
GetBreakoutCountResponse,
getBreakoutData,
GetBreakoutDataResponse,
handleinviteDismissedAt,
} from './queries';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { BREAKOUT_ROOM_REQUEST_JOIN_URL } from '../../breakout-room/mutations';
const intlMessages = defineMessages({
title: {
id: 'app.breakoutJoinConfirmation.title',
description: 'Join breakout room title',
},
message: {
id: 'app.breakoutJoinConfirmation.message',
description: 'Join breakout confirm message',
},
freeJoinMessage: {
id: 'app.breakoutJoinConfirmation.freeJoinMessage',
description: 'Join breakout confirm message',
},
confirmLabel: {
id: 'app.createBreakoutRoom.join',
description: 'Join confirmation button label',
},
confirmDesc: {
id: 'app.breakoutJoinConfirmation.confirmDesc',
description: 'adds context to confirm option',
},
dismissLabel: {
id: 'app.breakoutJoinConfirmation.dismissLabel',
description: 'Cancel button label',
},
dismissDesc: {
id: 'app.breakoutJoinConfirmation.dismissDesc',
description: 'adds context to dismiss option',
},
generatingURL: {
id: 'app.createBreakoutRoom.generatingURLMessage',
description: 'label for generating breakout room url',
},
});
interface BreakoutJoinConfirmationProps {
freeJoin: boolean;
breakouts: BreakoutRoom[];
currentUserJoined: boolean,
}
const BreakoutJoinConfirmation: React.FC<BreakoutJoinConfirmationProps> = ({
freeJoin,
breakouts,
currentUserJoined,
}) => {
const [breakoutRoomRequestJoinURL] = useMutation(BREAKOUT_ROOM_REQUEST_JOIN_URL);
const [callHandleinviteDismissedAt] = useMutation(handleinviteDismissedAt);
const intl = useIntl();
const [waiting, setWaiting] = React.useState(false);
const [isOpen, setIsOpen] = React.useState(false);
const [selectValue, setSelectValue] = React.useState('');
const requestJoinURL = (breakoutRoomId: string) => {
breakoutRoomRequestJoinURL({ variables: { breakoutRoomId } });
};
const handleSelectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectValue(event.target.value);
const selectedBreakout = breakouts.find(({ breakoutRoomId }) => breakoutRoomId === event.target.value);
if (!selectedBreakout?.joinURL) {
requestJoinURL(event.target.value);
setWaiting(true);
}
};
const handleJoinBreakoutConfirmation = () => {
if (breakouts.length === 1) {
const breakout = breakouts[0];
if (breakout?.joinURL) {
window.open(breakout.joinURL, '_blank');
}
setIsOpen(false);
} else {
const selectedBreakout = breakouts.find(({ breakoutRoomId }) => breakoutRoomId === selectValue);
if (selectedBreakout?.joinURL) {
window.open(selectedBreakout.joinURL, '_blank');
}
}
};
const select = useMemo(() => {
return (
<Styled.SelectParent>
{`${intl.formatMessage(intlMessages.freeJoinMessage)}`}
<Styled.Select
value={selectValue}
onChange={handleSelectChange}
disabled={waiting}
data-test="selectBreakoutRoomBtn"
>
{
breakouts.sort((a, b) => a.sequence - b.sequence).map(({ shortName, breakoutRoomId }) => (
<option
data-test="roomOption"
key={breakoutRoomId}
value={breakoutRoomId}
>
{shortName}
</option>
))
}
</Styled.Select>
{ waiting ? <span data-test="labelGeneratingURL">{intl.formatMessage(intlMessages.generatingURL)}</span> : null}
</Styled.SelectParent>
);
}, [breakouts, waiting, selectValue]);
useEffect(() => {
if (waiting) {
const breakout = breakouts.find(({ breakoutRoomId }) => breakoutRoomId === selectValue);
if (breakout?.joinURL) {
setWaiting(false);
}
}
}, [breakouts, waiting]);
useEffect(() => {
if (breakouts?.length > 0 && !currentUserJoined) {
setIsOpen(true);
}
}, [breakouts, currentUserJoined]);
return (
<ModalFullscreen
title={intl.formatMessage(intlMessages.title)}
confirm={{
callback: handleJoinBreakoutConfirmation,
label: intl.formatMessage(intlMessages.confirmLabel),
description: intl.formatMessage(intlMessages.confirmDesc),
icon: 'popout_window',
disabled: waiting,
}}
dismiss={{
callback: () => {
setIsOpen(false);
callHandleinviteDismissedAt();
},
label: intl.formatMessage(intlMessages.dismissLabel),
description: intl.formatMessage(intlMessages.dismissDesc),
}}
{...{
setIsOpen,
isOpen,
priority: 'medium',
}}
>
{freeJoin ? select : `${intl.formatMessage(intlMessages.message)} ${breakouts[0].shortName}?`}
</ModalFullscreen>
);
};
const BreakoutJoinConfirmationContainer: React.FC = () => {
const { data: currentUser } = useCurrentUser((u) => {
return {
isModerator: u.isModerator,
breakoutRooms: u.breakoutRooms,
};
});
const {
data: breakoutData,
} = useSubscription<GetBreakoutDataResponse>(getBreakoutData);
const {
data: breakoutCountData,
} = useSubscription<GetBreakoutCountResponse>(getBreakoutCount);
if (!breakoutCountData || !breakoutCountData.breakoutRoom_aggregate.aggregate.count) return null;
if (!breakoutData || breakoutData.breakoutRoom.length === 0) return null;
const firstBreakout = breakoutData.breakoutRoom[0];
const {
freeJoin,
sendInvitationToModerators,
} = firstBreakout;
if (!sendInvitationToModerators && currentUser?.isModerator) return null;
return (
<BreakoutJoinConfirmation
freeJoin={freeJoin}
breakouts={breakoutData.breakoutRoom}
currentUserJoined={currentUser?.breakoutRooms?.currentRoomJoined ?? false}
/>
);
};
export default BreakoutJoinConfirmationContainer;

View File

@ -0,0 +1,62 @@
import { gql } from '@apollo/client';
export interface BreakoutRoom {
freeJoin: boolean;
shortName: string;
sendInvitationToModerators: boolean;
sequence: number;
showInvitation: boolean;
joinURL: string | null;
breakoutRoomId: string;
}
export interface GetBreakoutDataResponse {
breakoutRoom: BreakoutRoom[];
}
export interface BreakoutRoomAggregate {
aggregate: {
count: number;
};
}
export interface GetBreakoutCountResponse {
breakoutRoom_aggregate: BreakoutRoomAggregate;
}
export const handleinviteDismissedAt = gql`
mutation {
update_breakoutRoom_user(where: {}, _set: {inviteDismissedAt: "now()"}) {
affected_rows
}
}
`;
export const getBreakoutCount = gql`
subscription getBreakoutCount {
breakoutRoom_aggregate (where: {showInvitation: {_eq: true}}) {
aggregate {
count
}
}
}
`;
export const getBreakoutData = gql`
subscription getBreakoutData {
breakoutRoom {
freeJoin
shortName
sendInvitationToModerators
sequence
showInvitation
joinURL
breakoutRoomId
}
}
`;
export default {
getBreakoutCount,
getBreakoutData,
};

View File

@ -0,0 +1,20 @@
import styled from 'styled-components';
import { colorWhite, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
const SelectParent = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
const Select = styled.select`
background-color: ${colorWhite};
width: 50%;
margin: 1rem;
border-color: ${colorGrayLighter};
`;
export default {
SelectParent,
Select,
};

View File

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import BBBMenu from '/imports/ui/components/common/menu/component';
import CreateBreakoutRoomContainerGraphql from '/imports/ui/components/breakout-room/breakout-room-graphql/create-breakout-room/component';
import Trigger from '/imports/ui/components/common/control-header/right/component';
const intlMessages = defineMessages({
options: {
id: 'app.breakout.dropdown.options',
description: 'Breakout options label',
},
manageDuration: {
id: 'app.breakout.dropdown.manageDuration',
description: 'Manage duration label',
},
manageUsers: {
id: 'app.breakout.dropdown.manageUsers',
description: 'Manage users label',
},
destroy: {
id: 'app.breakout.dropdown.destroyAll',
description: 'Destroy breakouts label',
},
});
interface BreakoutDropdownProps {
openBreakoutTimeManager: () => void;
endAllBreakouts: () => void;
isMeteorConnected: boolean;
amIModerator: boolean;
isRTL: boolean;
}
interface MenuItem {
key: string;
dataTest: string;
label: string;
disabled?: boolean;
onClick: () => void;
}
const BreakoutDropdown: React.FC<BreakoutDropdownProps> = ({
openBreakoutTimeManager,
endAllBreakouts,
isMeteorConnected,
amIModerator,
isRTL,
}) => {
const intl = useIntl();
const [isCreateBreakoutRoomModalOpen, setIsCreateBreakoutRoomModalOpen] = useState(false);
const getAvailableActions = (): MenuItem[] => {
const menuItems: MenuItem[] = [];
menuItems.push({
key: 'breakoutTimeManager',
dataTest: 'openBreakoutTimeManager',
label: intl.formatMessage(intlMessages.manageDuration),
onClick: () => {
openBreakoutTimeManager();
},
});
menuItems.push({
key: 'updateBreakoutUsers',
dataTest: 'openUpdateBreakoutUsersModal',
label: intl.formatMessage(intlMessages.manageUsers),
onClick: () => {
setIsCreateBreakoutRoomModalOpen(true);
},
});
if (amIModerator) {
menuItems.push({
key: 'endAllBreakouts',
dataTest: 'endAllBreakouts',
label: intl.formatMessage(intlMessages.destroy),
disabled: !isMeteorConnected,
onClick: () => {
endAllBreakouts();
},
});
}
return menuItems;
};
const setCreateBreakoutRoomModalIsOpen = (value: boolean) => {
setIsCreateBreakoutRoomModalOpen(value);
};
return (
<>
<BBBMenu
trigger={
(
<Trigger
data-test="breakoutOptionsMenu"
icon="more"
label={intl.formatMessage(intlMessages.options)}
aria-label={intl.formatMessage(intlMessages.options)}
onClick={() => null}
/>
)
}
opts={{
id: 'breakoutroom-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getcontentanchorel: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
}}
actions={getAvailableActions()}
/>
{isCreateBreakoutRoomModalOpen ? (
<CreateBreakoutRoomContainerGraphql
isUpdate
priority="low"
setIsOpen={setCreateBreakoutRoomModalIsOpen}
isOpen={isCreateBreakoutRoomModalOpen}
/>
) : null}
</>
);
};
export default BreakoutDropdown;

View File

@ -0,0 +1,382 @@
import { useMutation, useSubscription } from '@apollo/client';
import React, { useCallback, useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
BreakoutRoom,
GetBreakoutDataResponse,
GetIfUserJoinedBreakoutRoomResponse,
getBreakoutData,
getIfUserJoinedBreakoutRoom,
} from './queries';
import logger from '/imports/startup/client/logger';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import Header from '/imports/ui/components/common/control-header/component';
import Styled from './styles';
import { layoutDispatch, layoutSelect } from '../../../layout/context';
import { ACTIONS, PANELS } from '../../../layout/enums';
import { Layout } from '../../../layout/layoutTypes';
import BreakoutDropdown from '../breakout-room-dropdown/component';
import { BREAKOUT_ROOM_END_ALL, BREAKOUT_ROOM_REQUEST_JOIN_URL, USER_TRANSFER_VOICE_TO_MEETING } from '../../mutations';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import TimeRemaingPanel from './components/timeRemaining';
import BreakoutMessageForm from './components/messageForm';
import { CAMERA_BROADCAST_STOP } from '../../../video-provider/mutations';
import {
finishScreenShare,
forceExitAudio,
rejoinAudio,
stopVideo,
} from './service';
interface BreakoutRoomProps {
breakouts: BreakoutRoom[];
isModerator: boolean;
presenter: boolean;
durationInSeconds: number;
userJoinedRooms: number;
userJoinedAudio: boolean;
userId: string;
meetingId: string;
}
const intlMessages = defineMessages({
breakoutTitle: {
id: 'app.createBreakoutRoom.title',
description: 'breakout title',
},
breakoutAriaTitle: {
id: 'app.createBreakoutRoom.ariaTitle',
description: 'breakout aria title',
},
breakoutDuration: {
id: 'app.createBreakoutRoom.duration',
description: 'breakout duration time',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
breakoutJoin: {
id: 'app.createBreakoutRoom.join',
description: 'label for join breakout room',
},
breakoutJoinAudio: {
id: 'app.createBreakoutRoom.joinAudio',
description: 'label for option to transfer audio',
},
breakoutReturnAudio: {
id: 'app.createBreakoutRoom.returnAudio',
description: 'label for option to return audio',
},
askToJoin: {
id: 'app.createBreakoutRoom.askToJoin',
description: 'label for generate breakout room url',
},
generatingURL: {
id: 'app.createBreakoutRoom.generatingURL',
description: 'label for generating breakout room url',
},
endAllBreakouts: {
id: 'app.createBreakoutRoom.endAllBreakouts',
description: 'Button label to end all breakout rooms',
},
chatTitleMsgAllRooms: {
id: 'app.createBreakoutRoom.chatTitleMsgAllRooms',
description: 'chat title for send message to all rooms',
},
alreadyConnected: {
id: 'app.createBreakoutRoom.alreadyConnected',
description: 'label for the user that is already connected to breakout room',
},
setTimeInMinutes: {
id: 'app.createBreakoutRoom.setTimeInMinutes',
description: 'Label for input to set time (minutes)',
},
setTimeLabel: {
id: 'app.createBreakoutRoom.setTimeLabel',
description: 'Button label to set breakout rooms time',
},
setTimeCancel: {
id: 'app.createBreakoutRoom.setTimeCancel',
description: 'Button label to cancel set breakout rooms time',
},
setTimeHigherThanMeetingTimeError: {
id: 'app.createBreakoutRoom.setTimeHigherThanMeetingTimeError',
description: 'Label for error when new breakout rooms time would be higher than remaining time in parent meeting',
},
});
const BreakoutRoom: React.FC<BreakoutRoomProps> = ({
breakouts,
isModerator,
durationInSeconds,
presenter,
userJoinedRooms,
userJoinedAudio,
userId,
meetingId,
}) => {
const [breakoutRoomEndAll] = useMutation(BREAKOUT_ROOM_END_ALL);
const [breakoutRoomTransfer] = useMutation(USER_TRANSFER_VOICE_TO_MEETING);
const [breakoutRoomRequestJoinURL] = useMutation(BREAKOUT_ROOM_REQUEST_JOIN_URL);
const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP);
const layoutContextDispatch = layoutDispatch();
const isRTL = layoutSelect((i: Layout) => i.isRTL);
const intl = useIntl();
const panelRef = React.useRef<HTMLDivElement>(null);
const [showChangeTimeForm, setShowChangeTimeForm] = React.useState(false);
const [requestedBreakoutRoomId, setRequestedBreakoutRoomId] = React.useState<string>('');
const [joinedRooms, setJoinedRooms] = React.useState<number>(0);
const sendUserUnshareWebcam = (cameraId: string) => {
cameraBroadcastStop({ variables: { cameraId } });
};
const transferUserToMeeting = (fromMeeting: string, toMeeting: string) => {
breakoutRoomTransfer(
{
variables: {
fromMeetingId: fromMeeting,
toMeetingId: toMeeting,
},
},
);
};
const requestJoinURL = (breakoutRoomId: string) => {
breakoutRoomRequestJoinURL({ variables: { breakoutRoomId } });
};
const closePanel = useCallback(() => {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
}, []);
useEffect(() => {
if (userJoinedRooms !== joinedRooms) {
setJoinedRooms((prev) => {
if (userJoinedRooms === 0 && prev > 0) {
rejoinAudio();
}
return userJoinedRooms;
});
}
}, [userJoinedRooms]);
useEffect(() => {
if (requestedBreakoutRoomId) {
const breakout = breakouts.find((b) => b.breakoutRoomId === requestedBreakoutRoomId);
if (breakout && breakout.joinURL) {
window.open(breakout.joinURL, '_blank');
setRequestedBreakoutRoomId('');
}
}
}, [breakouts]);
return (
<Styled.Panel
ref={panelRef}
onCopy={(e) => {
e.preventDefault();
}}
>
<Header
leftButtonProps={{
'aria-label': intl.formatMessage(intlMessages.breakoutAriaTitle),
label: intl.formatMessage(intlMessages.breakoutTitle),
onClick: closePanel,
}}
data-test="breakoutRoomManagerHeader"
rightButtonProps={{}}
customRightButton={isModerator && (
<BreakoutDropdown
openBreakoutTimeManager={() => setShowChangeTimeForm(true)}
endAllBreakouts={() => {
closePanel();
breakoutRoomEndAll();
}}
isMeteorConnected
amIModerator={isModerator}
isRTL={isRTL}
/>
)}
/>
<TimeRemaingPanel
showChangeTimeForm={showChangeTimeForm}
isModerator={isModerator}
durationInSeconds={durationInSeconds}
toggleShowChangeTimeForm={setShowChangeTimeForm}
/>
{isModerator ? <BreakoutMessageForm /> : null}
{isModerator ? <Styled.Separator /> : null}
<Styled.BreakoutsList>
{
breakouts.map((breakout) => {
const breakoutLabel = breakout.joinURL
? intl.formatMessage(intlMessages.breakoutJoin)
: intl.formatMessage(intlMessages.askToJoin);
const dataTest = `${breakout.joinURL ? 'join' : 'askToJoin'}${breakout.shortName.replace(' ', '')}`;
const userJoinedDialin = breakout.participants.find((p) => p.userId === userId)?.isAudioOnly ?? false;
return (
<Styled.BreakoutItems key={`breakoutRoomItems-${breakout.breakoutRoomId}`}>
<Styled.Content key={`breakoutRoomList-${breakout.breakoutRoomId}`}>
<Styled.BreakoutRoomListNameLabel data-test={breakout.shortName} aria-hidden>
{breakout.isDefaultName
? intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })
: breakout.shortName}
<Styled.UsersAssignedNumberLabel>
(
{breakout.participants.length}
)
</Styled.UsersAssignedNumberLabel>
</Styled.BreakoutRoomListNameLabel>
{requestedBreakoutRoomId === breakout.breakoutRoomId ? (
<span>
{intl.formatMessage(intlMessages.generatingURL)}
<Styled.ConnectingAnimation animations />
</span>
) : (
<Styled.BreakoutActions>
{
breakout.currentRoomJoined
? (
<Styled.AlreadyConnected data-test="alreadyConnected">
{intl.formatMessage(intlMessages.alreadyConnected)}
</Styled.AlreadyConnected>
)
: (
<Styled.JoinButton
label={breakoutLabel}
data-test={dataTest}
aria-label={`${breakoutLabel} ${breakout.shortName}`}
onClick={() => {
if (!breakout.joinURL) {
setRequestedBreakoutRoomId(breakout.breakoutRoomId);
requestJoinURL(breakout.breakoutRoomId);
} else {
window.open(breakout.joinURL, '_blank');
// leave main room's audio,
// and stops video and screenshare when joining a breakout room
forceExitAudio();
stopVideo(sendUserUnshareWebcam);
logger.info({
logCode: 'breakoutroom_join',
extraInfo: { logType: 'user_action' },
}, 'joining breakout room closed audio in the main room');
if (presenter) finishScreenShare();
}
}}
disabled={requestedBreakoutRoomId}
/>
)
}
{
isModerator && (userJoinedAudio || userJoinedDialin)
? [
('|'),
(
<Styled.AudioButton
label={
userJoinedDialin
? intl.formatMessage(intlMessages.breakoutReturnAudio)
: intl.formatMessage(intlMessages.breakoutJoinAudio)
}
disabled={false}
key={`join-audio-${breakout.breakoutRoomId}`}
onClick={
userJoinedDialin ? () => transferUserToMeeting(breakout.breakoutRoomId, meetingId)
: () => transferUserToMeeting(meetingId, breakout.breakoutRoomId)
}
/>
),
]
: null
}
</Styled.BreakoutActions>
)}
</Styled.Content>
<Styled.JoinedUserNames
data-test={`userNameBreakoutRoom-${breakout.shortName}`}
>
{breakout.participants
.filter((p) => !p.isAudioOnly)
.sort((a, b) => a.user.nameSortable.localeCompare(b.user.nameSortable))
.map((u) => u.user.name)
.join(', ')}
</Styled.JoinedUserNames>
</Styled.BreakoutItems>
);
})
}
</Styled.BreakoutsList>
</Styled.Panel>
);
};
const BreakoutRoomContainer: React.FC = () => {
const {
data: meetingData,
} = useMeeting((m) => ({
durationInSeconds: m.durationInSeconds,
meetingId: m.meetingId,
}));
const {
data: currentUserData,
loading: currentUserLoading,
} = useCurrentUser((u) => ({
isModerator: u.isModerator,
presenter: u.presenter,
voice: u.voice,
userId: u.userId,
}));
const {
data: getIfUserJoinedBreakoutRoomData,
loading: getIfUserJoinedBreakoutRoomLoading,
} = useSubscription<GetIfUserJoinedBreakoutRoomResponse>(getIfUserJoinedBreakoutRoom);
const {
data: breakoutData,
loading: breakoutLoading,
error: breakoutError,
} = useSubscription<GetBreakoutDataResponse>(getBreakoutData);
if (
breakoutLoading
|| currentUserLoading
|| getIfUserJoinedBreakoutRoomLoading
) return null;
if (breakoutError) {
logger.error(breakoutError);
return (
<div>
Error:
{JSON.stringify(breakoutError)}
</div>
);
}
if (!currentUserData || !breakoutData || !meetingData) return null; // or loading spinner or error
return (
<BreakoutRoom
breakouts={breakoutData.breakoutRoom || []}
isModerator={currentUserData.isModerator ?? false}
presenter={currentUserData.presenter ?? false}
durationInSeconds={meetingData.durationInSeconds ?? 0}
userJoinedRooms={getIfUserJoinedBreakoutRoomData?.breakoutRoom_aggregate.aggregate.count ?? 0}
userJoinedAudio={currentUserData?.voice?.joined ?? false}
userId={currentUserData.userId ?? ''}
meetingId={meetingData.meetingId ?? ''}
/>
);
};
export default BreakoutRoomContainer;

View File

@ -0,0 +1,158 @@
import { useMutation } from '@apollo/client';
import React, { useCallback, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { escapeHtml } from '/imports/utils/string-utils';
import { BREAKOUT_ROOM_SEND_MESSAGE_TO_ALL } from '../../../mutations';
import Styled from '../styles';
const CHAT_CONFIG = window.meetingClientSettings.public.chat;
const minMessageLength = CHAT_CONFIG.min_message_length;
const maxMessageLength = CHAT_CONFIG.max_message_length;
const intlMessages = defineMessages({
submitLabel: {
id: 'app.chat.submitLabel',
description: 'Chat submit button label',
},
inputLabel: {
id: 'app.chat.inputLabel',
description: 'Chat message input label',
},
inputPlaceholder: {
id: 'app.chat.inputPlaceholder',
description: 'Chat message input placeholder',
},
errorMaxMessageLength: {
id: 'app.chat.errorMaxMessageLength',
},
errorMinMessageLength: {
id: 'app.chat.errorMinMessageLength',
},
errorServerDisconnected: {
id: 'app.chat.disconnected',
},
errorChatLocked: {
id: 'app.chat.locked',
},
chatTitleMsgAllRooms: {
id: 'app.createBreakoutRoom.chatTitleMsgAllRooms',
description: 'chat title for send message to all rooms',
},
});
const BreakoutMessageForm: React.FC = () => {
const intl = useIntl();
const [message, setMessage] = React.useState('');
const [error, setError] = React.useState('');
const [hasErrors, setHasErrors] = React.useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>();
const chatTitle = useRef(intl.formatMessage(intlMessages.chatTitleMsgAllRooms));
const [sendMessageToAllBreakouts] = useMutation(BREAKOUT_ROOM_SEND_MESSAGE_TO_ALL);
useEffect(() => {
const unSentMessage = sessionStorage.getItem('breakoutUnsentMessage');
if (unSentMessage) {
setMessage(unSentMessage);
}
}, []);
const verifyForErrors = useCallback((message: string) => {
if (message.length < minMessageLength) {
if (!hasErrors) setHasErrors(true);
setError(intl.formatMessage(intlMessages.errorMaxMessageLength, { minMessageLength }));
} else if ((message.length > maxMessageLength) && !hasErrors) {
if (!hasErrors) setHasErrors(true);
setError(intl.formatMessage(intlMessages.errorMaxMessageLength, { maxMessageLength }));
return true;
} else {
setHasErrors(false);
setError('');
return false;
}
return false;
}, []);
const editMessage = useCallback((message: string) => {
verifyForErrors(message);
setMessage(message);
sessionStorage.setItem('breakoutUnsentMessage', message);
textAreaRef?.current?.focus();
}, []);
const sendMessage = useCallback((message: string) => {
sendMessageToAllBreakouts({
variables: {
message,
},
});
}, []);
const handleSendMessage = useCallback((message: string) => {
if (!verifyForErrors(message)) {
sendMessage(escapeHtml(message));
setMessage('');
setError('');
setHasErrors(false);
sessionStorage.removeItem('breakoutUnsentMessage');
}
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage(message);
}
}, [message]);
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
handleSendMessage(message);
}, [message]);
const handleOnChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
editMessage(e.target.value);
}, []);
return (
<Styled.Form
onSubmit={handleSubmit}
>
<Styled.Wrapper>
<Styled.Input
id="message-input"
innerRef={(ref) => { textAreaRef.current = ref; }}
placeholder={intl.formatMessage(intlMessages.inputPlaceholder, { 0: chatTitle.current })}
aria-label={intl.formatMessage(intlMessages.inputLabel, { 0: chatTitle.current })}
aria-invalid={hasErrors ? 'true' : 'false'}
autoCorrect="off"
autoComplete="off"
spellCheck="true"
value={message}
onChange={handleOnChange}
onKeyDown={handleKeyDown}
async
onPaste={(e) => { e.stopPropagation(); }}
onCut={(e) => { e.stopPropagation(); }}
onCopy={(e) => { e.stopPropagation(); }}
/>
<Styled.SendButton
hideLabel
circle
aria-label={intl.formatMessage(intlMessages.submitLabel)}
type="submit"
disabled={hasErrors || !message}
label={intl.formatMessage(intlMessages.submitLabel)}
color="primary"
icon="send"
onClick={() => {}}
data-test="sendMessageButton"
/>
</Styled.Wrapper>
{ hasErrors ? <Styled.ErrorMessage>{error}</Styled.ErrorMessage> : null }
</Styled.Form>
);
};
export default BreakoutMessageForm;

View File

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import { defineMessages, useIntl } from 'react-intl';
import BreakoutRemainingTime from '/imports/ui/components/common/remaining-time/breakout-duration/component';
import Styled from '../styles';
import { BREAKOUT_ROOM_SET_TIME } from '../../../mutations';
const intlMessages = defineMessages({
breakoutTitle: {
id: 'app.createBreakoutRoom.title',
description: 'breakout title',
},
breakoutAriaTitle: {
id: 'app.createBreakoutRoom.ariaTitle',
description: 'breakout aria title',
},
breakoutDuration: {
id: 'app.createBreakoutRoom.duration',
description: 'breakout duration time',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
breakoutJoin: {
id: 'app.createBreakoutRoom.join',
description: 'label for join breakout room',
},
breakoutJoinAudio: {
id: 'app.createBreakoutRoom.joinAudio',
description: 'label for option to transfer audio',
},
breakoutReturnAudio: {
id: 'app.createBreakoutRoom.returnAudio',
description: 'label for option to return audio',
},
askToJoin: {
id: 'app.createBreakoutRoom.askToJoin',
description: 'label for generate breakout room url',
},
generatingURL: {
id: 'app.createBreakoutRoom.generatingURL',
description: 'label for generating breakout room url',
},
endAllBreakouts: {
id: 'app.createBreakoutRoom.endAllBreakouts',
description: 'Button label to end all breakout rooms',
},
chatTitleMsgAllRooms: {
id: 'app.createBreakoutRoom.chatTitleMsgAllRooms',
description: 'chat title for send message to all rooms',
},
alreadyConnected: {
id: 'app.createBreakoutRoom.alreadyConnected',
description: 'label for the user that is already connected to breakout room',
},
setTimeInMinutes: {
id: 'app.createBreakoutRoom.setTimeInMinutes',
description: 'Label for input to set time (minutes)',
},
setTimeLabel: {
id: 'app.createBreakoutRoom.setTimeLabel',
description: 'Button label to set breakout rooms time',
},
setTimeCancel: {
id: 'app.createBreakoutRoom.setTimeCancel',
description: 'Button label to cancel set breakout rooms time',
},
setTimeHigherThanMeetingTimeError: {
id: 'app.createBreakoutRoom.setTimeHigherThanMeetingTimeError',
description: 'Label for error when new breakout rooms time would be higher than remaining time in parent meeting',
},
});
interface TimeRemainingPanelProps {
showChangeTimeForm: boolean;
isModerator: boolean;
durationInSeconds: number;
toggleShowChangeTimeForm: (value: boolean) => void;
}
const TimeRemaingPanel: React.FC<TimeRemainingPanelProps> = ({
showChangeTimeForm,
isModerator,
durationInSeconds,
toggleShowChangeTimeForm,
}) => {
const intl = useIntl();
const durationContainerRef = React.useRef(null);
const [showFormError, setShowFormError] = useState(false);
const [newTime, setNewTime] = useState(0);
const [breakoutRoomSetTime] = useMutation(BREAKOUT_ROOM_SET_TIME);
const setBreakoutsTime = (timeInMinutes: number) => {
if (timeInMinutes <= 0) return false;
return breakoutRoomSetTime({ variables: { timeInMinutes } });
};
return (
<Styled.DurationContainer
centeredText={!showChangeTimeForm}
ref={durationContainerRef}
>
<BreakoutRemainingTime
boldText
/>
{isModerator && showChangeTimeForm ? (
<Styled.SetTimeContainer>
<label htmlFor="inputSetTimeSelector">
{intl.formatMessage(intlMessages.setTimeInMinutes)}
</label>
<br />
<Styled.FlexRow>
<Styled.SetDurationInput
id="inputSetTimeSelector"
type="number"
min="1"
value={newTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const newSetTime = Number.parseInt(e.target.value, 10) || 0;
setNewTime(newSetTime);
}}
aria-label={intl.formatMessage(intlMessages.setTimeInMinutes)}
/>
&nbsp;
&nbsp;
<Styled.EndButton
data-test="sendButtonDurationTime"
color="primary"
disabled={false}
size="sm"
label={intl.formatMessage(intlMessages.setTimeLabel)}
onClick={() => {
setShowFormError(false);
if (durationInSeconds !== 0 && newTime > durationInSeconds) {
setShowFormError(true);
} else if (setBreakoutsTime(newTime)) {
toggleShowChangeTimeForm(false);
}
}}
/>
</Styled.FlexRow>
{showFormError ? (
<Styled.WithError>
{intl.formatMessage(intlMessages.setTimeHigherThanMeetingTimeError)}
</Styled.WithError>
) : null}
</Styled.SetTimeContainer>
) : null}
</Styled.DurationContainer>
);
};
export default TimeRemaingPanel;

View File

@ -0,0 +1,72 @@
import { gql } from '@apollo/client';
export interface BreakoutRoom {
freeJoin: boolean;
shortName: string;
sendInvitationToModerators: boolean;
sequence: number;
showInvitation: boolean;
joinURL: string | null;
breakoutRoomId: string;
isDefaultName: boolean;
currentRoomJoined: boolean;
participants: Array<{
userId: string;
isAudioOnly: string;
user: {
name: string;
nameSortable: string;
}
}>
}
export interface GetBreakoutDataResponse {
breakoutRoom: BreakoutRoom[];
}
export interface GetIfUserJoinedBreakoutRoomResponse {
breakoutRoom_aggregate:{
aggregate: {
count: number;
}
};
}
export const getBreakoutData = gql`
subscription getBreakoutData {
breakoutRoom(order_by: {sequence: asc}){
freeJoin
shortName
sendInvitationToModerators
sequence
showInvitation
joinURL
breakoutRoomId
isDefaultName
currentRoomJoined
participants {
userId
isAudioOnly
user {
name
nameSortable
}
}
}
}
`;
export const getIfUserJoinedBreakoutRoom = gql`
subscription getIdUserJoinedABreakout {
breakoutRoom_aggregate(where: {currentRoomJoined: {_eq: true}}) {
aggregate {
count
}
}
}
`;
export default {
getBreakoutData,
getIfUserJoinedBreakoutRoom,
};

View File

@ -0,0 +1,67 @@
import { Meteor } from 'meteor/meteor';
import AudioService from '/imports/ui/components/audio/service';
import { makeCall } from '/imports/ui/services/api';
import AudioManager from '/imports/ui/services/audio-manager';
import VideoService from '/imports/ui/components/video-provider/service';
import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
import { didUserSelectedListenOnly, didUserSelectedMicrophone } from '../../../audio/audio-modal/service';
import logger from '/imports/startup/client/logger';
export const getIsMicrophoneUser = () => {
return (AudioService.isConnectedToBreakout() || AudioService.isConnected())
&& !AudioService.isListenOnly();
};
export const getIsReconnecting = () => {
return AudioService.isReconnecting();
};
export const getIsConnected = () => {
return Meteor.status().connected;
};
export const endAllBreakouts = () => {
makeCall('endAllBreakouts');
};
export const forceExitAudio = () => {
AudioManager.forceExitAudio();
};
export const stopVideo = (unshareVideo: (stream: string)=> void) => {
VideoService.storeDeviceIds();
VideoService.exitVideo(unshareVideo);
};
export const finishScreenShare = () => {
return screenshareHasEnded();
};
const logUserCouldNotRejoinAudio = () => {
logger.warn({
logCode: 'mainroom_audio_rejoin',
extraInfo: { logType: 'user_action' },
}, 'leaving breakout room couldn\'t rejoin audio in the main room');
};
export const rejoinAudio = () => {
if (didUserSelectedMicrophone()) {
AudioManager.joinMicrophone().catch(() => {
logUserCouldNotRejoinAudio();
});
} else if (didUserSelectedListenOnly()) {
AudioManager.joinListenOnly().catch(() => {
logUserCouldNotRejoinAudio();
});
}
};
export default {
getIsMicrophoneUser,
getIsReconnecting,
endAllBreakouts,
forceExitAudio,
stopVideo,
finishScreenShare,
rejoinAudio,
};

View File

@ -0,0 +1,373 @@
import styled, { css, keyframes } from 'styled-components';
import {
mdPaddingX,
borderSize,
borderSizeSmall,
borderRadius,
jumboPaddingY,
smPaddingX,
smPaddingY,
} from '/imports/ui/stylesheets/styled-components/general';
import {
colorPrimary,
colorGray,
colorDanger,
userListBg,
colorWhite,
colorGrayLighter,
colorGrayLightest,
colorBlueLight,
listItemBgHover,
colorText,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
headingsFontWeight,
fontSizeSmall,
fontSizeBase,
} from '/imports/ui/stylesheets/styled-components/typography';
import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
import Button from '/imports/ui/components/common/button/component';
import TextareaAutosize from 'react-autosize-textarea';
const BreakoutActions = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
font-weight: ${headingsFontWeight};
color: ${colorPrimary};
& > button {
padding: 0 0 0 .5rem;
}
`;
const AlreadyConnected = styled.span`
padding: 0 .5rem 0 0;
display: inline-block;
vertical-align: middle;
white-space: nowrap;
`;
// @ts-ignore - as button comes from JS, we can't provide its props
const JoinButton = styled(Button)`
flex: 0 1 48%;
color: ${colorPrimary};
margin: 0;
font-weight: inherit;
padding: 0 .5rem 0 .5rem !important;
`;
// @ts-ignore - as button comes from JS, we can't provide its props
const AudioButton = styled(Button)`
flex: 0 1 48%;
color: ${colorPrimary};
margin: 0;
font-weight: inherit;
`;
const BreakoutItems = styled.div`
margin-bottom: 1rem;
`;
const Content = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: ${fontSizeSmall};
font-weight: bold;
padding: ${borderSize} ${borderSize} ${borderSize} 0;
[dir="rtl"] & {
padding: ${borderSize} 0 ${borderSize} ${borderSize};
}
`;
const BreakoutRoomListNameLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
`;
const UsersAssignedNumberLabel = styled.span`
margin: 0 0 0 .25rem;
[dir="rtl"] & {
margin: 0 .25em 0 0;
}
`;
const ellipsis = keyframes`
to {
width: 1.5em;
}
`;
type ConnectingAnimationProps = {
animations: boolean;
};
const ConnectingAnimation = styled.span<ConnectingAnimationProps>`
&:after {
overflow: hidden;
display: inline-block;
vertical-align: bottom;
content: "\\2026"; /* ascii code for the ellipsis character */
width: 0;
margin: 0 1.25em 0 0;
[dir="rtl"] & {
margin: 0 0 0 1.25em;
}
${({ animations }) => animations && css`
animation: ${ellipsis} steps(4, end) 900ms infinite;
`}
}
`;
const BreakoutsList = styled.div`
overflow: auto;
`;
const JoinedUserNames = styled.div`
overflow-wrap: break-word;
white-space: pre-line;
margin-left: 1rem;
font-size: ${fontSizeSmall};
`;
const BreakoutColumn = styled.div`
display: flex;
flex-flow: column;
min-height: 0;
flex-grow: 1;
`;
const BreakoutScrollableList = styled(ScrollboxVertical)`
background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)),
linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%,
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
outline: transparent;
outline-style: dotted;
outline-width: ${borderSize};
&:focus {
outline: none;
border-radius: ${borderSize};
box-shadow: 0 0 0 ${borderSize} ${listItemBgHover}, inset 0 0 0 1px ${colorPrimary};
}
&:focus-within,
&:focus {
outline-style: solid;
}
&:active {
box-shadow: none;
border-radius: none;
}
overflow-x: hidden;
outline-width: 1px !important;
outline-color: transparent !important;
background: none;
`;
type DurationContainerProps = {
centeredText: boolean;
};
const DurationContainer = styled.div<DurationContainerProps>`
${({ centeredText }) => centeredText && `
text-align: center;
`}
border-radius: ${borderRadius};
margin-bottom: ${jumboPaddingY};
padding: 10px;
box-shadow: 0 0 1px 1px ${colorGrayLightest};
`;
const SetTimeContainer = styled.div`
margin: .5rem 0 0 0;
`;
const SetDurationInput = styled.input`
flex: 1;
border: 1px solid ${colorGrayLighter};
width: 50%;
text-align: center;
padding: .25rem;
border-radius: ${borderRadius};
background-clip: padding-box;
outline: none;
&::placeholder {
color: ${colorGray};
opacity: 1;
}
&:focus {
border-radius: ${borderSize};
box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
}
&:disabled,
&[disabled] {
cursor: not-allowed;
opacity: .75;
background-color: rgba(167,179,189,0.25);
}
`;
const WithError = styled.span`
color: ${colorDanger};
`;
// @ts-ignore - as button comes from JS, we can't provide its props
const EndButton = styled(Button)`
padding: .5rem;
font-weight: ${headingsFontWeight} !important;
border-radius: .2rem;
font-size: ${fontSizeSmall};
`;
const Duration = styled.span`
display: inline-block;
align-self: center;
`;
const Panel = styled(ScrollboxVertical)`
background: linear-gradient(${colorWhite} 30%, rgba(255,255,255,0)),
linear-gradient(rgba(255,255,255,0), ${colorWhite} 70%) 0 100%,
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
background-color: #fff;
padding: ${mdPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;
overflow: hidden;
height: 100%;
`;
const Separator = styled.div`
position: relative;
width: 100%;
height: 10px;
height: ${borderSizeSmall};
background-color: ${colorGrayLighter};
margin: 30px 0px;
`;
const FlexRow = styled.div`
display: flex;
flex-wrap: nowrap;
`;
const Form = styled.form`
flex-grow: 0;
flex-shrink: 0;
align-self: flex-end;
width: 100%;
position: relative;
margin-bottom: calc(-1 * ${smPaddingX});
margin-top: .2rem;
`;
const Wrapper = styled.div`
display: flex;
flex-direction: row;
`;
const Input = styled(TextareaAutosize)`
flex: 1;
background: #fff;
background-clip: padding-box;
margin: 0;
color: ${colorText};
-webkit-appearance: none;
padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25);
resize: none;
transition: none;
border-radius: ${borderRadius};
font-size: ${fontSizeBase};
line-height: 1;
min-height: 2.5rem;
max-height: 10rem;
border: 1px solid ${colorGrayLighter};
&:disabled,
&[disabled] {
cursor: not-allowed;
opacity: .75;
background-color: rgba(167,179,189,0.25);
}
&:focus {
border-radius: ${borderSize};
box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
}
&:hover,
&:active,
&:focus {
outline: transparent;
outline-style: dotted;
outline-width: ${borderSize};
}
`;
// @ts-ignore - as button comes from JS, we can't provide its props
const SendButton = styled(Button)`
margin:0 0 0 ${smPaddingX};
align-self: center;
font-size: 0.9rem;
[dir="rtl"] & {
margin: 0 ${smPaddingX} 0 0;
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
`;
const ErrorMessage = styled.div`
color: ${colorDanger};
font-size: calc(${fontSizeBase} * .75);
text-align: left;
padding: ${borderSize} 0;
position: relative;
height: .93rem;
max-height: .93rem;
`;
export default {
BreakoutActions,
AlreadyConnected,
JoinButton,
AudioButton,
BreakoutItems,
Content,
BreakoutRoomListNameLabel,
UsersAssignedNumberLabel,
ConnectingAnimation,
JoinedUserNames,
BreakoutColumn,
BreakoutScrollableList,
DurationContainer,
SetTimeContainer,
SetDurationInput,
WithError,
EndButton,
Duration,
Panel,
Separator,
FlexRow,
Form,
Wrapper,
Input,
SendButton,
ErrorMessage,
BreakoutsList,
};

View File

@ -35,7 +35,7 @@ const DEFAULT_BREAKOUT_TIME = 15;
interface CreateBreakoutRoomContainerProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
priority: number,
priority: string,
isUpdate?: boolean,
}

View File

@ -20,6 +20,7 @@ import {
import logger from '/imports/startup/client/logger';
import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations';
import useToggleVoice from '../audio/audio-graphql/hooks/useToggleVoice';
import BreakoutContainerGraphql from '/imports/ui/components/breakout-room/breakout-room-graphql/breakout-room/component';
const BreakoutContainer = (props) => {
const layoutContextDispatch = layoutDispatch();
@ -92,15 +93,7 @@ const BreakoutContainer = (props) => {
}
};
return <BreakoutComponent
amIPresenter={amIPresenter}
endAllBreakouts={endAllBreakouts}
setBreakoutsTime={setBreakoutsTime}
transferUserToMeeting={transferUserToMeeting}
requestJoinURL={requestJoinURL}
sendUserUnshareWebcam={sendUserUnshareWebcam}
{...{ layoutContextDispatch, isRTL, amIModerator, rejoinAudio, ...props }}
/>;
return <BreakoutContainerGraphql />;
};
export default withTracker((props) => {

View File

@ -4,7 +4,6 @@ import { withTracker } from 'meteor/react-meteor-data';
import deviceInfo from '/imports/utils/deviceInfo';
import browserInfo from '/imports/utils/browserInfo';
import OptionsDropdown from './component';
import audioCaptionsService from '/imports/ui/components/audio/captions/service';
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
import { layoutSelectInput, layoutSelect } from '../../layout/context';
@ -18,6 +17,9 @@ const { isIphone } = deviceInfo;
const { isSafari, isValidSafariVersion } = browserInfo;
const noIOSFullscreen = !!(((isSafari && !isValidSafariVersion) || isIphone));
const getAudioCaptions = () => Session.get('audioCaptions') || false;
const setAudioCaptions = (value) => Session.set('audioCaptions', value);
const OptionsDropdownContainer = (props) => {
const { width: browserWidth } = layoutSelectInput((i) => i.browser);
@ -56,8 +58,8 @@ export default withTracker((props) => {
const handleToggleFullscreen = () => FullscreenService.toggleFullScreen();
return {
amIModerator: props.amIModerator,
audioCaptionsActive: audioCaptionsService.getAudioCaptions(),
audioCaptionsSet: (value) => audioCaptionsService.setAudioCaptions(value),
audioCaptionsActive: getAudioCaptions(),
audioCaptionsSet: (value) => setAudioCaptions(value),
isMobile: deviceInfo.isMobile,
handleToggleFullscreen,
noIOSFullscreen,

View File

@ -152,20 +152,15 @@ export default injectIntl(withTracker(({ intl }) => {
}
const meetingId = Auth.meetingID;
const breakouts = breakoutService.getBreakouts();
if (breakouts.length > 0) {
const currentBreakout = breakouts.find((b) => b.breakoutId === meetingId);
const Meeting = Meetings.findOne({ meetingId },
{ fields: { isBreakout: 1, componentsFlags: 1 } });
if (currentBreakout) {
if (Meeting.isBreakout) {
data.message = (
<MeetingRemainingTime />
);
}
}
const Meeting = Meetings.findOne({ meetingId },
{ fields: { isBreakout: 1, componentsFlags: 1 } });
if (Meeting) {
const { isBreakout, componentsFlags } = Meeting;

View File

@ -0,0 +1,89 @@
import { useSubscription } from '@apollo/client';
import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Notification, NotificationResponse, getNotificationsStream } from './queries';
import useCurrentUser from '../../core/hooks/useCurrentUser';
import { notify } from '../../services/notification';
import {
NotifyPublishedPoll,
layoutUpdate,
pendingGuestAlert,
userJoinPushAlert,
userLeavePushAlert,
} from './service';
const Notifications: React.FC = () => {
const [registeredAt, setRegisteredAt] = React.useState<string>(new Date().toISOString());
const [greaterThanLastOne, setGreaterThanLastOne] = React.useState<number>(0);
const messageIndexRef = React.useRef<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]:(...arg: any[]) => void
}>({
'app.whiteboard.annotations.poll': NotifyPublishedPoll,
'app.userList.guest.pendingGuestAlert': pendingGuestAlert,
'app.notification.userJoinPushAlert': userJoinPushAlert,
'app.notification.userLeavePushAlert': userLeavePushAlert,
'app.layoutUpdate.label': layoutUpdate,
});
const {
data: currentUser,
} = useCurrentUser((u) => ({
registeredAt: u.registeredAt,
presenter: u.presenter,
isModerator: u.isModerator,
}));
const {
data: notificationsStream,
} = useSubscription<NotificationResponse>(getNotificationsStream, {
variables: { initialCursor: '2024-04-18' },
});
const notifier = (notification: Notification) => {
notify(
<FormattedMessage
id={notification.messageId}
// @ts-ignore - JS code
values={notification.messageValues}
description={notification.messageDescription}
/>,
notification.notificationType,
notification.icon,
);
};
useEffect(() => {
if (currentUser && currentUser.registeredAt) {
if (registeredAt !== currentUser.registeredAt) {
setRegisteredAt(currentUser.registeredAt);
}
}
}, [currentUser]);
useEffect(() => {
if (notificationsStream && notificationsStream.notification_stream.length > 0) {
notificationsStream.notification_stream.forEach((notification: Notification) => {
const createdAt = new Date(notification.createdAt).getTime();
if (createdAt > greaterThanLastOne) {
setGreaterThanLastOne(createdAt);
// Do something with the notification
if (messageIndexRef.current[notification.messageId]) {
messageIndexRef.current[notification.messageId](
notification,
notifier,
currentUser?.isModerator,
currentUser?.presenter,
);
} else {
notifier(notification);
}
}
});
}
}, [notificationsStream]);
return null;
};
export default Notifications;

View File

@ -1,68 +0,0 @@
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { Notifications as NotificationsCollection } from '/imports/api/meetings';
import { notify } from '/imports/ui/services/notification';
import { withTracker } from 'meteor/react-meteor-data';
import WaitingUsersAlertService from '/imports/ui/components/waiting-users/alert/service';
import UserService from '/imports/ui/components/user-list/service';
import Settings from '/imports/ui/services/settings';
import useCurrentUser from '../../core/hooks/useCurrentUser';
const injectCurrentUser = (Component) => (props) => {
const { data: user } = useCurrentUser((u) => ({
presenter: u.presenter,
}));
return (
<Component
{...props}
currentUser={user}
/>
);
};
export default injectIntl(injectCurrentUser(withTracker(({ intl, currentUser }) => {
NotificationsCollection.find({}).observe({
added: (obj) => {
NotificationsCollection.remove(obj);
if (
obj.messageId === 'app.whiteboard.annotations.poll'
&& Settings.application.chatPushAlerts
&& !currentUser?.presenter
) return null;
if (obj.messageId === 'app.userList.guest.pendingGuestAlert') {
return WaitingUsersAlertService.alert(obj, intl);
}
if (obj.messageId === 'app.notification.userJoinPushAlert') {
return UserService.UserJoinedMeetingAlert(obj);
}
if (obj.messageId === 'app.notification.userLeavePushAlert') {
return UserService.UserLeftMeetingAlert(obj);
}
if (obj.messageId === 'app.layoutUpdate.label') {
const last = new Date(Session.get('lastLayoutUpdateNotification'));
const now = new Date();
if (now - last < 1000) {
return {};
}
Session.set('lastLayoutUpdateNotification', now);
}
return notify(
<FormattedMessage
id={obj.messageId}
values={obj.messageValues}
description={obj.messageDescription}
/>,
obj.notificationType,
obj.icon,
);
},
});
return {};
})(() => null)));

View File

@ -0,0 +1,33 @@
import { gql } from '@apollo/client';
export interface Notification {
notificationType: string;
icon: string;
messageId: string;
messageValues: string[];
isSingleUserNotification: boolean;
createdAt: string; // You might want to use a Date type if you're parsing this string into a Date object
notificationId: number;
messageDescription: string;
}
export interface NotificationResponse {
notification_stream: Notification[];
}
export const getNotificationsStream = gql`
subscription getNotificationStream($initialCursor: timestamptz!){
notification_stream(batch_size: 10, cursor: {initial_value: {createdAt: $initialCursor}}) {
notificationType
icon
messageId
messageValues
isSingleUserNotification
createdAt
}
}
`;
export default {
getNotificationsStream,
};

View File

@ -0,0 +1,119 @@
import { makeVar } from '@apollo/client';
import { Notification } from './queries';
import Settings from '/imports/ui/services/settings';
import { throttle } from '/imports/utils/throttle';
const CDN = window.meetingClientSettings.public.app.cdn;
const BASENAME = window.meetingClientSettings.public.app.basename;
const HOST = CDN + BASENAME;
const GUEST_WAITING_BELL_THROTTLE_TIME = 10000;
const lastLayoutUpdateNotification = makeVar(new Date().getTime());
export const NotifyPublishedPoll = (
notification: Notification,
notifier: (notification: Notification) => void,
isModerator: boolean,
presenter: boolean,
) => {
if (
(presenter || isModerator)
) {
notifier(notification);
}
};
function ringGuestWaitingBell() {
// @ts-ignore - JS code
if (Settings.application.guestWaitingAudioAlerts) {
const audio = new Audio(`${HOST}/resources/sounds/doorbell.mp3`);
audio.play();
}
}
const ringGuestWaitingBellThrottled = throttle(
ringGuestWaitingBell,
GUEST_WAITING_BELL_THROTTLE_TIME,
{ leading: true, trailing: false },
);
export const pendingGuestAlert = (
notification: Notification,
notifier: (notification: Notification) => void,
) => {
// @ts-ignore - JS code
if (Settings.application.guestWaitingPushAlerts) {
notifier(notification);
}
ringGuestWaitingBellThrottled();
};
export const userJoinPushAlert = (
notification: Notification,
notifier: (notification: Notification) => void,
) => {
const {
userJoinAudioAlerts,
userJoinPushAlerts,
// @ts-ignore - JS code
} = Settings.application;
if (!userJoinAudioAlerts && !userJoinPushAlerts) return;
if (userJoinAudioAlerts) {
new Audio(`${window.meetingClientSettings.public.app.cdn
+ window.meetingClientSettings.public.app.basename
+ window.meetingClientSettings.public.app.instanceId}`
+ '/resources/sounds/userJoin.mp3').play();
}
if (userJoinPushAlerts) {
notifier(notification);
}
};
export const userLeavePushAlert = (
notification: Notification,
notifier: (notification: Notification) => void,
) => {
const {
userLeaveAudioAlerts,
userLeavePushAlerts,
// @ts-ignore - JS code
} = Settings.application;
if (!userLeaveAudioAlerts && !userLeavePushAlerts) return;
if (userLeaveAudioAlerts) {
new Audio(`${window.meetingClientSettings.public.app.cdn
+ window.meetingClientSettings.public.app.basename
+ window.meetingClientSettings.public.app.instanceId}`
+ '/resources/sounds/userJoin.mp3').play();
}
if (userLeavePushAlerts) {
notifier(notification);
}
};
export const layoutUpdate = (
notification: Notification,
notifier: (notification: Notification) => void,
) => {
const last = new Date(lastLayoutUpdateNotification()).getTime();
const now = new Date().getTime();
if (now - last < 1000) {
return;
}
lastLayoutUpdateNotification(now);
notifier(notification);
};
export default {
NotifyPublishedPoll,
pendingGuestAlert,
userJoinPushAlert,
userLeavePushAlert,
layoutUpdate,
};

View File

@ -59,7 +59,7 @@ class RecordingComponent extends PureComponent {
let title;
if (!recordingStatus) {
title = recordingTime >= 0 ? intl.formatMessage(intlMessages.resumeTitle)
title = recordingTime > 0 ? intl.formatMessage(intlMessages.resumeTitle)
: intl.formatMessage(intlMessages.startTitle);
} else {
title = intl.formatMessage(intlMessages.stopTitle);

View File

@ -1,30 +1,42 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { RecordMeetings } from '/imports/api/meetings';
import Auth from '/imports/ui/services/auth';
import { useMutation } from '@apollo/client';
import { useMutation, useSubscription } from '@apollo/client';
import RecordingComponent from './component';
import { SET_RECORDING_STATUS } from './mutations';
import { GET_RECORDINGS } from './queries';
const RecordingContainer = (props) => <RecordingComponent {...props} />;
export default withTracker(({ setIsOpen }) => {
const { recording, time } = RecordMeetings.findOne({ meetingId: Auth.meetingID });
const RecordingContainer = (props) => {
const { setIsOpen } = props;
const [setRecordingStatus] = useMutation(SET_RECORDING_STATUS);
const {
data: recordingData,
} = useSubscription(GET_RECORDINGS);
return ({
toggleRecording: () => {
const recording = recordingData?.meeting_recording[0]?.isRecording ?? false;
const time = recordingData?.meeting_recording[0]?.previousRecordedTimeInSeconds ?? 0;
const toggleRecording = () => {
setRecordingStatus({
variables: {
recording: !recording,
},
});
setIsOpen(false);
},
};
return (
<RecordingComponent {
...{
recordingStatus: recording,
recordingTime: time,
isMeteorConnected: Meteor.status().connected,
toggleRecording,
...props,
}
}
/>
);
};
});
})(RecordingContainer);
export default withTracker(({ setIsOpen }) => ({
isMeteorConnected: Meteor.status().connected,
setIsOpen,
}))(RecordingContainer);

View File

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_RECORDINGS = gql`
subscription getRecordingData {
meeting_recording {
isRecording
previousRecordedTimeInSeconds
}
}
`;
export default {
GET_RECORDINGS,
};

View File

@ -6,7 +6,7 @@ import ChatContainer from '/imports/ui/components/chat/chat-graphql/component';
import NotesContainer from '/imports/ui/components/notes/container';
import PollContainer from '/imports/ui/components/poll/container';
import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container';
import TimerContainer from '/imports/ui/components/timer/container';
import TimerContainer from '/imports/ui/components/timer/timer-graphql/panel/component';
import GuestUsersManagementPanel from '/imports/ui/components/waiting-users/waiting-users-graphql/component';
import Styled from './styles';
import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';

View File

@ -20,15 +20,15 @@ const SUBSCRIPTIONS = [
'users-settings',
'users-infos',
'meeting-time-remaining',
'record-meetings',
// 'record-meetings',
'video-streams',
// 'voice-call-states',
'breakouts',
// 'breakouts',
// 'breakouts-history',
'pads',
'pads-sessions',
'pads-updates',
'notifications',
// 'notifications',
'layout-meetings',
'user-reaction',
'timer',

View File

@ -1,6 +1,6 @@
import { useSubscription, useMutation } from '@apollo/client';
import React, { useEffect, useRef, useState } from 'react';
import GET_TIMER, { GetTimerResponse } from './queries';
import GET_TIMER, { GetTimerResponse } from '../queries';
import logger from '/imports/startup/client/logger';
import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/icon-ts/component';
@ -83,6 +83,10 @@ const TimerIndicator: React.FC<TimerIndicatorProps> = ({
};
}, [songTrack]);
useEffect(() => {
setTime(passedTime);
}, []);
useEffect(() => {
alarm.current = new Audio(`${HOST}/resources/sounds/alarm.mp3`);
}, []);
@ -222,8 +226,7 @@ const TimerIndicatorContainer: React.FC = () => {
const timePassed = stopwatch ? (
Math.floor(((running ? timeDifferenceMs : 0) + accumulated))
) : (
Math.floor(((time) - accumulated))
);
Math.floor(((time) - (accumulated + (running ? timeDifferenceMs : 0)))));
return (
<TimerIndicator

View File

@ -0,0 +1,466 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
useMutation,
useSubscription,
} from '@apollo/client';
import Header from '/imports/ui/components/common/control-header/component';
import Styled from './styles';
import GET_TIMER, { GetTimerResponse, TimerData } from '../queries';
import logger from '/imports/startup/client/logger';
import { layoutDispatch } from '../../../layout/context';
import { ACTIONS, PANELS } from '../../../layout/enums';
import {
TIMER_RESET,
TIMER_SET_SONG_TRACK,
TIMER_SET_TIME,
TIMER_START,
TIMER_STOP,
TIMER_SWITCH_MODE,
} from '../../mutations';
import useTimeSync from '/imports/ui/core/local-states/useTimeSync';
import humanizeSeconds from '/imports/utils/humanizeSeconds';
const MAX_HOURS = 23;
const MILLI_IN_HOUR = 3600000;
const MILLI_IN_MINUTE = 60000;
const MILLI_IN_SECOND = 1000;
const TIMER_CONFIG = window.meetingClientSettings.public.timer;
const TRACKS = [
'noTrack',
'track1',
'track2',
'track3',
];
const intlMessages = defineMessages({
hideTimerLabel: {
id: 'app.timer.hideTimerLabel',
description: 'Label for hiding timer button',
},
title: {
id: 'app.timer.title',
description: 'Title for timer',
},
stopwatch: {
id: 'app.timer.button.stopwatch',
description: 'Stopwatch switch button',
},
timer: {
id: 'app.timer.button.timer',
description: 'Timer switch button',
},
start: {
id: 'app.timer.button.start',
description: 'Timer start button',
},
stop: {
id: 'app.timer.button.stop',
description: 'Timer stop button',
},
reset: {
id: 'app.timer.button.reset',
description: 'Timer reset button',
},
hours: {
id: 'app.timer.hours',
description: 'Timer hours label',
},
minutes: {
id: 'app.timer.minutes',
description: 'Timer minutes label',
},
seconds: {
id: 'app.timer.seconds',
description: 'Timer seconds label',
},
songs: {
id: 'app.timer.songs',
description: 'Musics title label',
},
noTrack: {
id: 'app.timer.noTrack',
description: 'No music radio label',
},
track1: {
id: 'app.timer.track1',
description: 'Track 1 radio label',
},
track2: {
id: 'app.timer.track2',
description: 'Track 2 radio label',
},
track3: {
id: 'app.timer.track3',
description: 'Track 3 radio label',
},
});
interface TimerPanelProps extends TimerData {
timePassed: number;
}
const TimerPanel: React.FC<TimerPanelProps> = ({
stopwatch,
songTrack,
time,
running,
timePassed,
startedOn,
}) => {
const [timerReset] = useMutation(TIMER_RESET);
const [timerStart] = useMutation(TIMER_START);
const [timerStop] = useMutation(TIMER_STOP);
const [timerSwitchMode] = useMutation(TIMER_SWITCH_MODE);
const [timerSetSongTrack] = useMutation(TIMER_SET_SONG_TRACK);
const [timerSetTime] = useMutation(TIMER_SET_TIME);
const intl = useIntl();
const layoutContextDispatch = layoutDispatch();
const [runningTime, setRunningTime] = useState<number>(0);
const intervalRef = useRef<ReturnType<typeof setInterval>>();
const headerMessage = useMemo(() => {
return stopwatch ? intlMessages.stopwatch : intlMessages.timer;
}, [stopwatch]);
const switchTimer = (stopwatch: boolean) => {
timerSwitchMode({ variables: { stopwatch } });
};
const setTrack = (track: string) => {
timerSetSongTrack({ variables: { track } });
};
const setTime = (time: number) => {
timerSetTime({ variables: { time } });
timerStop();
timerReset();
};
const setHours = useCallback((hours: number, time: number) => {
if (!Number.isNaN(hours) && hours >= 0 && hours <= MAX_HOURS) {
const currentHours = Math.floor(time / MILLI_IN_HOUR);
const diff = (hours - currentHours) * MILLI_IN_HOUR;
setTime(time + diff);
} else {
logger.warn('Invalid time');
}
}, []);
const setMinutes = useCallback((minutes: number, time: number) => {
if (!Number.isNaN(minutes) && minutes >= 0 && minutes <= 59) {
const currentHours = Math.floor(time / MILLI_IN_HOUR);
const mHours = currentHours * MILLI_IN_HOUR;
const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE);
const diff = (minutes - currentMinutes) * MILLI_IN_MINUTE;
setTime(time + diff);
} else {
logger.warn('Invalid time');
}
}, []);
const setSeconds = useCallback((seconds: number, time: number) => {
if (!Number.isNaN(seconds) && seconds >= 0 && seconds <= 59) {
const currentHours = Math.floor(time / MILLI_IN_HOUR);
const mHours = currentHours * MILLI_IN_HOUR;
const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE);
const mMinutes = currentMinutes * MILLI_IN_MINUTE;
const currentSeconds = Math.floor((time - mHours - mMinutes) / MILLI_IN_SECOND);
const diff = (seconds - currentSeconds) * MILLI_IN_SECOND;
setTime(time + diff);
} else {
logger.warn('Invalid time');
}
}, []);
const closePanel = useCallback(() => {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
}, []);
useEffect(() => {
setRunningTime(timePassed);
}, []);
// if startedOn is 0, means the time was reset
useEffect(() => {
if (startedOn === 0) {
setRunningTime(timePassed);
}
}, [startedOn]);
// updates the timer every second locally
useEffect(() => {
if (running) {
setRunningTime(timePassed < 0 ? 0 : timePassed);
intervalRef.current = setInterval(() => {
setRunningTime((prev) => {
const calcTime = (Math.round(prev / 1000) * 1000);
if (stopwatch) {
return (calcTime < 0 ? 0 : calcTime) + 1000;
}
const t = (calcTime) - 1000;
return t < 0 ? 0 : t;
});
}, 1000);
} else if (!running) {
clearInterval(intervalRef.current);
}
}, [running]);
// sync local time with server time
useEffect(() => {
if (!running) return;
const time = timePassed >= 0 ? timePassed : 0;
setRunningTime((prev) => {
if (time) return timePassed;
return prev;
});
}, [timePassed, stopwatch, startedOn]);
const timerControls = useMemo(() => {
const timeFormatedString = humanizeSeconds(Math.floor(time / 1000));
const timeSplit = timeFormatedString.split(':');
const hours = timeSplit.length > 2 ? parseInt(timeSplit[0], 10) : 0;
const minutes = timeSplit.length > 2 ? parseInt(timeSplit[1], 10) : parseInt(timeSplit[0], 10);
const seconds = timeSplit.length > 2 ? parseInt(timeSplit[2], 10) : parseInt(timeSplit[1], 10);
const label = running ? intlMessages.stop : intlMessages.start;
const color = running ? 'danger' : 'primary';
return (
<div>
{
!stopwatch ? (
<Styled.StopwatchTime>
<Styled.StopwatchTimeInput>
<Styled.TimerInput
type="number"
disabled={stopwatch}
defaultValue={hours}
maxLength={2}
max={MAX_HOURS}
min="0"
onChange={(event) => {
setHours(parseInt(event.target.value || '00', 10), time);
}}
data-test="hoursInput"
/>
<Styled.StopwatchTimeInputLabel>
{intl.formatMessage(intlMessages.hours)}
</Styled.StopwatchTimeInputLabel>
</Styled.StopwatchTimeInput>
<Styled.StopwatchTimeColon>:</Styled.StopwatchTimeColon>
<Styled.StopwatchTimeInput>
<Styled.TimerInput
type="number"
disabled={stopwatch}
defaultValue={minutes}
maxLength={2}
max="59"
min="0"
onChange={(event) => {
setMinutes(parseInt(event.target.value || '00', 10), time);
}}
data-test="minutesInput"
/>
<Styled.StopwatchTimeInputLabel>
{intl.formatMessage(intlMessages.minutes)}
</Styled.StopwatchTimeInputLabel>
</Styled.StopwatchTimeInput>
<Styled.StopwatchTimeColon>:</Styled.StopwatchTimeColon>
<Styled.StopwatchTimeInput>
<Styled.TimerInput
type="number"
disabled={stopwatch}
defaultValue={seconds}
maxLength={2}
max="59"
min="0"
onChange={(event) => {
setSeconds(parseInt(event.target.value || '00', 10), time);
}}
data-test="secondsInput"
/>
<Styled.StopwatchTimeInputLabel>
{intl.formatMessage(intlMessages.seconds)}
</Styled.StopwatchTimeInputLabel>
</Styled.StopwatchTimeInput>
</Styled.StopwatchTime>
) : null
}
{TIMER_CONFIG.music.enabled && !stopwatch
? (
<Styled.TimerSongsWrapper>
<Styled.TimerSongsTitle
stopwatch={stopwatch}
>
{intl.formatMessage(intlMessages.songs)}
</Styled.TimerSongsTitle>
<Styled.TimerTracks>
{TRACKS.map((track) => (
<Styled.TimerTrackItem
key={track}
>
<label htmlFor={track}>
<input
type="radio"
name="track"
id={track}
value={track}
checked={songTrack === track}
onChange={(event) => {
setTrack(event.target.value);
}}
disabled={stopwatch}
/>
{intl.formatMessage(intlMessages[track as keyof typeof intlMessages])}
</label>
</Styled.TimerTrackItem>
))}
</Styled.TimerTracks>
</Styled.TimerSongsWrapper>
) : null}
<Styled.TimerControls>
<Styled.TimerControlButton
color={color}
label={intl.formatMessage(label)}
onClick={() => {
if (running) {
timerStop();
} else {
timerStart();
}
}}
data-test="startStopTimer"
/>
<Styled.TimerControlButton
color="secondary"
label={intl.formatMessage(intlMessages.reset)}
onClick={() => {
timerStop();
timerReset();
}}
data-test="resetTimerStopWatch"
/>
</Styled.TimerControls>
</div>
);
}, [songTrack, stopwatch, time, running]);
return (
<Styled.TimerSidebarContent
data-test="timer"
>
{/* @ts-ignore - JS code */}
<Header
leftButtonProps={{
onClick: closePanel,
'aria-label': intl.formatMessage(intlMessages.hideTimerLabel),
label: intl.formatMessage(headerMessage),
}}
data-test="timerHeader"
/>
<Styled.TimerContent>
<Styled.TimerCurrent
aria-hidden
data-test="timerCurrent"
>
{humanizeSeconds(Math.floor(runningTime / 1000))}
</Styled.TimerCurrent>
<Styled.TimerType>
<Styled.TimerSwitchButton
label={intl.formatMessage(intlMessages.stopwatch)}
onClick={() => {
timerStop();
switchTimer(true);
}}
disabled={stopwatch}
color={stopwatch ? 'primary' : 'secondary'}
data-test="stopwatch"
/>
<Styled.TimerSwitchButton
label={intl.formatMessage(intlMessages.timer)}
onClick={() => {
timerStop();
switchTimer(false);
}}
disabled={!stopwatch}
color={!stopwatch ? 'primary' : 'secondary'}
data-test="timer"
/>
</Styled.TimerType>
{timerControls}
</Styled.TimerContent>
</Styled.TimerSidebarContent>
);
};
const TimerPanelContaier: React.FC = () => {
const [timeSync] = useTimeSync();
const {
loading: timerLoading,
error: timerError,
data: timerData,
} = useSubscription<GetTimerResponse>(GET_TIMER);
if (timerLoading || !timerData) return null;
if (timerError) {
logger.error('TimerIndicatorContainer', timerError);
return (<div>{JSON.stringify(timerError)}</div>);
}
const timer = timerData.timer[0];
const currentDate: Date = new Date();
const startedAtDate: Date = new Date(timer.startedAt);
const adjustedCurrent: Date = new Date(currentDate.getTime() + timeSync);
const timeDifferenceMs: number = adjustedCurrent.getTime() - startedAtDate.getTime();
const timePassed = timer.stopwatch ? (
Math.floor(((timer.running ? timeDifferenceMs : 0) + timer.accumulated))
) : (
Math.floor(((timer.time) - (timer.accumulated + (timer.running ? timeDifferenceMs : 0)))));
return (
<TimerPanel
stopwatch={timer.stopwatch ?? false}
songTrack={timer.songTrack ?? 'noTrack'}
running={timer.running ?? false}
timePassed={timePassed}
accumulated={timer.accumulated}
active={timer.active ?? false}
time={timer.time}
endedOn={timer.endedOn}
startedOn={timer.startedOn}
startedAt={timer.startedAt}
/>
);
};
export default TimerPanelContaier;

View File

@ -0,0 +1,260 @@
import styled from 'styled-components';
import {
borderSize,
borderSizeLarge,
smPaddingX,
toastContentWidth,
borderRadius,
} from '../../../../stylesheets/styled-components/general';
import {
colorGrayDark,
colorGrayLighter,
colorGrayLightest,
colorGray,
colorBlueLight,
colorWhite,
colorPrimary,
} from '../../../../stylesheets/styled-components/palette';
import { TextElipsis } from '../../../../stylesheets/styled-components/placeholders';
import Button from '/imports/ui/components/common/button/component';
const TimerSidebarContent = styled.div`
background-color: ${colorWhite};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: space-around;
overflow: hidden;
height: 100%;
transform: translateZ(0);
`;
const TimerHeader = styled.header`
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
`;
const TimerTitle = styled.div`
${TextElipsis};
flex: 1;
& > button, button:hover {
max-width: ${toastContentWidth};
}
`;
// @ts-ignore - JS code
const TimerMinimizeButton = styled(Button)`
position: relative;
background-color: ${colorWhite};
display: block;
margin: ${borderSizeLarge};
margin-bottom: ${borderSize};
padding-left: 0;
padding-right: inherit;
[dir="rtl"] & {
padding-left: inherit;
padding-right: 0;
}
> i {
color: ${colorGrayDark};
font-size: smaller;
[dir="rtl"] & {
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
}
&:hover {
background-color: ${colorWhite};
}
`;
const TimerContent = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;
const TimerCurrent = styled.span`
border-bottom: 1px solid ${colorGrayLightest};
border-top: 1px solid ${colorGrayLightest};
display: flex;
font-size: xxx-large;
justify-content: center;
`;
const TimerType = styled.div`
display: flex;
width: 100%;
justify-content: center;
padding-top: 2rem;
`;
// @ts-ignore - JS code
const TimerSwitchButton = styled(Button)`
width: 100%;
height: 2rem;
margin: 0 .5rem;
`;
const StopwatchTime = styled.div`
display: flex;
margin-top: 4rem;
width: 100%;
height: 3rem;
font-size: x-large;
justify-content: center;
input {
width: 5rem;
}
`;
const StopwatchTimeInput = styled.div`
display: flex;
flex-direction: column;
.label {
display: flex;
font-size: small;
justify-content: center;
}
`;
const StopwatchTimeInputLabel = styled.div`
display: flex;
font-size: small;
justify-content: center;
`;
const StopwatchTimeColon = styled.span`
align-self: center;
padding: 0 .25rem;
`;
const TimerSongsWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-flow: column;
margin-top: 4rem;
margin-bottom: -2rem;
`;
const TimerRow = `
display: flex;
flex-flow: row;
flex-grow: 1;
`;
const TimerCol = `
display: flex;
flex-flow: column;
flex-grow: 1;
flex-basis: 0;
`;
type TimerSongsTitleProps = {
stopwatch: boolean;
};
const TimerSongsTitle = styled.div<TimerSongsTitleProps>`
${TimerRow}
display: flex;
font-weight: bold;
font-size: 1.1rem;
opacity: ${({ stopwatch }) => (stopwatch ? '50%' : '100%')}
`;
const TimerTracks = styled.div`
${TimerCol}
display: flex;
margin-top: 0.8rem;
margin-bottom: 2rem;
.row {
margin: 0.5rem auto;
}
label {
display: flex;
}
input {
margin: auto 0.5rem;
}
`;
const TimerTrackItem = styled.div`
${TimerRow}
`;
const TimerControls = styled.div`
display: flex;
width: 100%;
justify-content: center;
margin-top: 4rem;
`;
// @ts-ignore - JS code
const TimerControlButton = styled(Button)`
width: 6rem;
margin: 0 1rem;
`;
const TimerInput = styled.input`
flex: 1;
border: 1px solid ${colorGrayLighter};
width: 50%;
text-align: center;
padding: .25rem;
border-radius: ${borderRadius};
background-clip: padding-box;
outline: none;
&::placeholder {
color: ${colorGray};
opacity: 1;
}
&:focus {
border-radius: ${borderSize};
box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
}
&:disabled,
&[disabled] {
cursor: not-allowed;
opacity: .75;
background-color: rgba(167,179,189,0.25);
}
`;
export default {
TimerSidebarContent,
TimerHeader,
TimerTitle,
TimerMinimizeButton,
TimerContent,
TimerCurrent,
TimerType,
TimerSwitchButton,
StopwatchTime,
StopwatchTimeInput,
StopwatchTimeInputLabel,
StopwatchTimeColon,
TimerSongsWrapper,
TimerSongsTitle,
TimerTracks,
TimerTrackItem,
TimerControls,
TimerControlButton,
TimerInput,
};

View File

@ -1,7 +1,6 @@
import { gql } from '@apollo/client';
export interface GetTimerResponse {
timer: Array<{
export interface TimerData {
accumulated: number;
active: boolean;
songTrack: string;
@ -10,7 +9,11 @@ export interface GetTimerResponse {
running: boolean;
startedOn: number;
endedOn: number;
}>;
startedAt: string;
}
export interface GetTimerResponse {
timer: Array<TimerData>;
}
export const GET_TIMER = gql`
@ -23,6 +26,7 @@ export const GET_TIMER = gql`
stopwatch
running
startedOn
startedAt
endedOn
}
}

View File

@ -1,35 +1,70 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import BreakoutRoomItem from './component';
import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
import Breakouts from '/imports/api/breakouts';
import Auth from '/imports/ui/services/auth';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import { useSubscription } from '@apollo/client';
import { userIsInvited } from './query';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { ACTIONS, PANELS } from '../../../layout/enums';
import logger from '/imports/startup/client/logger';
const BreakoutRoomContainer = ({ breakoutRoom }) => {
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
const { sidebarContentPanel } = sidebarContent;
const layoutContextDispatch = layoutDispatch();
const hasBreakoutRoom = !!breakoutRoom;
const {
data: userIsInvitedData,
error: userIsInvitedError,
loading: userIsInvitedLoading,
} = useSubscription(userIsInvited);
const {
data: currentMeeting,
} = useMeeting((m) => ({
componentsFlags: m.componentsFlags,
}));
const {
data: currentUser,
} = useCurrentUser((u) => ({
isModerator: u.isModerator,
}));
if (userIsInvitedError) {
logger.error('Error in userIsInvited subscription:', userIsInvitedError);
return (
<div>
{JSON.stringify(userIsInvitedError)}
</div>
);
}
if (userIsInvitedLoading) return null;
const hasBreakoutRoom = currentMeeting?.componentsFlags?.hasBreakoutRoom ?? false;
if (!hasBreakoutRoom && sidebarContentPanel === PANELS.BREAKOUT) {
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
value: PANELS.NONE,
});
}
return (
<BreakoutRoomItem {...{
layoutContextDispatch,
sidebarContentPanel,
hasBreakoutRoom,
hasBreakoutRoom: hasBreakoutRoom
&& (userIsInvitedData.breakoutRoom.length > 0 || currentUser.isModerator),
breakoutRoom,
}}
/>
);
};
export default withTracker(() => {
const breakoutRoom = Breakouts.findOne(
{ parentMeetingId: Auth.meetingID },
{ fields: { timeRemaining: 1 } },
);
return {
breakoutRoom,
};
})(BreakoutRoomContainer);
export default BreakoutRoomContainer;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const userIsInvited = gql`
subscription userIsInvited {
breakoutRoom(where: {joinURL: {_is_null: false}}) {
sequence
}
}
`;
export default {
userIsInvited,
};

View File

@ -142,6 +142,8 @@ const WhiteboardContainer = (props) => {
const publishCursorUpdate = (payload) => {
const { whiteboardId, xPercent, yPercent } = payload;
if (!whiteboardId || !xPercent || !yPercent) return;
presentationPublishCursor({
variables: {
whiteboardId,

View File

@ -48,6 +48,29 @@ subscription userCurrentSubscription {
parameter
value
}
breakoutRooms {
currentRoomJoined
assignedAt
breakoutRoomId
currentRoomPriority
currentRoomRegisteredAt
durationInSeconds
endedAt
freeJoin
inviteDismissedAt
isDefaultName
joinURL
lastRoomIsOnline
lastRoomJoinedAt
lastRoomJoinedId
name
sendInvitationToModerators
sequence
shortName
showInvitation
startedAt
currentRoomIsOnline
}
lastBreakoutRoom {
breakoutRoomId
currentlyInRoom

View File

@ -2,7 +2,7 @@ import { useContext } from 'react';
import { User } from '../../Types/user';
import { CurrentUserContext } from '../providers/current-user';
const useCurrentUser = (fn: (c: Partial<User>) => Partial<User>) => {
const useCurrentUser = (fn: (c: Partial<User>) => Partial<User> = (u) => u) => {
const response = useContext(CurrentUserContext);
const returnObject = {
...response,

View File

@ -482,6 +482,12 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = {
family: 'Calibri',
size: '24px',
},
locales: [
{
locale: 'en-US',
name: 'English',
},
],
lines: 2,
time: 5000,
},

View File

@ -587,6 +587,8 @@ public:
name: "Ελληνικά"
- locale: "en"
name: "English"
- locale: "en-US"
name: "English"
- locale: "eo"
name: "Esperanto"
- locale: "es"

View File

@ -3,6 +3,7 @@
"app.chat.submitLabel": "Send message",
"app.chat.loading": "Chat messages loaded: {0}%",
"app.chat.errorMaxMessageLength": "The message is too long, exceeded the maximum of {0} characters",
"app.chat.errorMinMessageLength": "The message did not reach the minimum of {0} characters",
"app.chat.disconnected": "You are disconnected, messages can't be sent",
"app.chat.locked": "Chat is locked, messages can't be sent",
"app.chat.inputLabel": "Message input for chat {0}",

View File

@ -34,7 +34,6 @@ class Create extends MultiUsers {
await this.modPage.waitAndClick(e.modalConfirmButton, ELEMENT_WAIT_LONGER_TIME);
await this.userPage.hasElement(e.modalConfirmButton);
await this.userPage.waitAndClick(e.modalDismissButton);
await this.modPage.hasElement(e.breakoutRoomsItem);
}

View File

@ -61,6 +61,7 @@ class Join extends Create {
await breakoutUserPage.hasElement(e.presentationTitle);
await this.modPage.waitAndClick(e.breakoutRoomsItem);
await this.modPage.hasElement(e.breakoutRemainingTime);
await this.modPage.type(e.chatBox, "test");
await this.modPage.waitAndClick(e.sendButton);
@ -234,6 +235,8 @@ class Join extends Create {
async userCanChooseRoom() {
await this.userPage.bringToFront();
await this.userPage.hasElementEnabled(e.selectBreakoutRoomBtn);
await this.userPage.hasElementEnabled(e.modalConfirmButton);
await this.userPage.checkElementCount(e.roomOption, 2);
await this.userPage.getLocator(`${e.fullscreenModal} >> select`).selectOption({index: 1});

View File

@ -94,6 +94,7 @@ exports.warningNoUserAssigned = 'span[data-test="warningNoUserAssigned"]';
exports.timeRemaining = 'span[data-test="timeRemaining"]';
exports.captureBreakoutSharedNotes = 'input[id="captureNotesBreakoutCheckbox"]';
exports.captureBreakoutWhiteboard = 'input[id="captureSlidesBreakoutCheckbox"]';
exports.selectBreakoutRoomBtn = 'select[data-test="selectBreakoutRoomBtn"]';
exports.roomOption = 'option[data-test="roomOption"]';
// Chat

View File

@ -314,6 +314,15 @@ class Page {
async setHeightWidthViewPortSize() {
await this.page.setViewportSize({ width: 1366, height: 768 });
}
async getYoutubeFrame() {
await this.waitForSelector(e.youtubeFrame);
const iframeElement = await this.getLocator('iframe').elementHandle();
const frame = await iframeElement.contentFrame();
await frame.waitForURL(/youtube/, { timeout: ELEMENT_WAIT_TIME });
const ytFrame = new Page(this.page.browser, frame);
return ytFrame;
}
}
module.exports = exports = Page;

View File

@ -72,8 +72,8 @@ class Presentation extends MultiUsers {
await this.modPage.type(e.videoModalInput, e.youtubeLink);
await this.modPage.waitAndClick(e.startShareVideoBtn);
const modFrame = await this.getFrame(this.modPage, e.youtubeFrame);
const userFrame = await this.getFrame(this.userPage, e.youtubeFrame);
const modFrame = await this.modPage.getYoutubeFrame();
const userFrame = await this.userPage.getYoutubeFrame();
await modFrame.hasElement('video');
await userFrame.hasElement('video');
@ -310,15 +310,6 @@ class Presentation extends MultiUsers {
await this.userPage.wasRemoved(e.presentationsList);
}
async getFrame(page, frameSelector) {
await page.waitForSelector(frameSelector);
const iframeElement = await page.getLocator('iframe').elementHandle();
const frame = await iframeElement.contentFrame();
await frame.waitForURL(/youtube/, { timeout: ELEMENT_WAIT_TIME });
const ytFrame = new Page(page.browser, frame);
return ytFrame;
}
async presentationFullscreen() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const presentationLocator = this.modPage.getLocator(e.presentationContainer);

View File

@ -24,6 +24,33 @@ class ScreenShare extends Page {
async testMobileDevice() {
await this.wasRemoved(e.startScreenSharing);
}
async screenshareStopsExternalVideo() {
const { screensharingEnabled } = getSettings();
await this.waitForSelector(e.whiteboard);
if(!screensharingEnabled) {
await this.hasElement(e.joinVideo);
return this.wasRemoved(e.startScreenSharing);
}
await this.waitAndClick(e.actions);
await this.waitAndClick(e.shareExternalVideoBtn);
await this.waitForSelector(e.closeModal);
await this.type(e.videoModalInput, e.youtubeLink);
await this.waitAndClick(e.startShareVideoBtn);
const modFrame = await this.getYoutubeFrame();
await modFrame.hasElement('video');
await startScreenshare(this);
await this.hasElement(e.isSharingScreen);
await this.hasElement(e.stopScreenSharing);
await this.waitAndClick(e.stopScreenSharing);
await this.hasElement(e.whiteboard);
}
}
class MultiUserScreenShare extends MultiUsers {

View File

@ -13,6 +13,12 @@ test.describe.parallel('Screenshare', () => {
await screenshare.startSharing();
});
test('Start screenshare stops external video @ci', async ({ browser, page }) => {
const screenshare = new ScreenShare(browser, page);
await screenshare.init(true, true);
await screenshare.screenshareStopsExternalVideo();
});
test.describe.parallel('Mobile', () => {
test.beforeEach(({ browserName }) => {
test.skip(browserName === 'firefox', 'Mobile tests are not able in Firefox browser');

View File

@ -1,5 +1,5 @@
const e = require('../core/elements');
const { VIDEO_LOADING_WAIT_TIME } = require('../core/constants');
const { VIDEO_LOADING_WAIT_TIME, ELEMENT_WAIT_TIME } = require('../core/constants');
async function startScreenshare(test) {
await test.waitAndClick(e.startScreenSharing);