Merge remote-tracking branch 'upstream/v3.0.x-release' into remove-voice-call-state
This commit is contained in:
commit
7165961444
@ -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';
|
||||
|
||||
|
@ -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
|
@ -16,7 +16,6 @@ select_permissions:
|
||||
- messageDescription
|
||||
- messageId
|
||||
- messageValues
|
||||
- notificationId
|
||||
- notificationType
|
||||
- role
|
||||
filter:
|
||||
|
@ -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"
|
||||
|
@ -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),
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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');
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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 });
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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;
|
@ -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 } };
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
&& (
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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);
|
@ -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);
|
@ -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;
|
@ -1,8 +0,0 @@
|
||||
const getAudioCaptions = () => Session.get('audioCaptions') || false;
|
||||
|
||||
const setAudioCaptions = (value) => Session.set('audioCaptions', value);
|
||||
|
||||
export default {
|
||||
getAudioCaptions,
|
||||
setAudioCaptions,
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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();
|
||||
|
@ -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));
|
||||
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
<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;
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -35,7 +35,7 @@ const DEFAULT_BREAKOUT_TIME = 15;
|
||||
interface CreateBreakoutRoomContainerProps {
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
priority: number,
|
||||
priority: string,
|
||||
isUpdate?: boolean,
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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)));
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_RECORDINGS = gql`
|
||||
subscription getRecordingData {
|
||||
meeting_recording {
|
||||
isRecording
|
||||
previousRecordedTimeInSeconds
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default {
|
||||
GET_RECORDINGS,
|
||||
};
|
@ -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';
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
@ -142,6 +142,8 @@ const WhiteboardContainer = (props) => {
|
||||
const publishCursorUpdate = (payload) => {
|
||||
const { whiteboardId, xPercent, yPercent } = payload;
|
||||
|
||||
if (!whiteboardId || !xPercent || !yPercent) return;
|
||||
|
||||
presentationPublishCursor({
|
||||
variables: {
|
||||
whiteboardId,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -482,6 +482,12 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = {
|
||||
family: 'Calibri',
|
||||
size: '24px',
|
||||
},
|
||||
locales: [
|
||||
{
|
||||
locale: 'en-US',
|
||||
name: 'English',
|
||||
},
|
||||
],
|
||||
lines: 2,
|
||||
time: 5000,
|
||||
},
|
||||
|
@ -587,6 +587,8 @@ public:
|
||||
name: "Ελληνικά"
|
||||
- locale: "en"
|
||||
name: "English"
|
||||
- locale: "en-US"
|
||||
name: "English"
|
||||
- locale: "eo"
|
||||
name: "Esperanto"
|
||||
- locale: "es"
|
||||
|
@ -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}",
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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});
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -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');
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user