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"
|
FROM "caption"
|
||||||
WHERE "createdAt" > current_timestamp - INTERVAL '5 seconds';
|
WHERE "createdAt" > current_timestamp - INTERVAL '5 seconds';
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW "v_caption_typed_activeLocales" AS
|
CREATE OR REPLACE VIEW "v_caption_activeLocales" AS
|
||||||
select distinct "meetingId", "locale", "ownerUserId"
|
select distinct "meetingId", "locale", "ownerUserId", "captionType"
|
||||||
from "caption_locale"
|
from "caption_locale";
|
||||||
where "captionType" = 'TYPED';
|
|
||||||
|
|
||||||
create index "idx_caption_typed_activeLocales" on caption("meetingId","locale","userId") where "captionType" = 'TYPED';
|
create index "idx_caption_typed_activeLocales" on caption("meetingId","locale","userId") where "captionType" = 'TYPED';
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
table:
|
table:
|
||||||
name: v_caption_typed_activeLocales
|
name: v_caption_activeLocales
|
||||||
schema: public
|
schema: public
|
||||||
configuration:
|
configuration:
|
||||||
column_config: {}
|
column_config: {}
|
||||||
custom_column_names: {}
|
custom_column_names: {}
|
||||||
custom_name: caption_typed_activeLocales
|
custom_name: caption_activeLocales
|
||||||
custom_root_fields: {}
|
custom_root_fields: {}
|
||||||
object_relationships:
|
object_relationships:
|
||||||
- name: userOwner
|
- name: userOwner
|
||||||
@ -22,6 +22,7 @@ select_permissions:
|
|||||||
permission:
|
permission:
|
||||||
columns:
|
columns:
|
||||||
- locale
|
- locale
|
||||||
|
- captionType
|
||||||
filter:
|
filter:
|
||||||
meetingId:
|
meetingId:
|
||||||
_eq: X-Hasura-MeetingId
|
_eq: X-Hasura-MeetingId
|
@ -16,7 +16,6 @@ select_permissions:
|
|||||||
- messageDescription
|
- messageDescription
|
||||||
- messageId
|
- messageId
|
||||||
- messageValues
|
- messageValues
|
||||||
- notificationId
|
|
||||||
- notificationType
|
- notificationType
|
||||||
- role
|
- role
|
||||||
filter:
|
filter:
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
- "!include public_v_breakoutRoom_participant.yaml"
|
- "!include public_v_breakoutRoom_participant.yaml"
|
||||||
- "!include public_v_breakoutRoom_user.yaml"
|
- "!include public_v_breakoutRoom_user.yaml"
|
||||||
- "!include public_v_caption.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.yaml"
|
||||||
- "!include public_v_chat_message_private.yaml"
|
- "!include public_v_chat_message_private.yaml"
|
||||||
- "!include public_v_chat_message_public.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 VoiceUsers from '/imports/api/voice-users';
|
||||||
import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
|
import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
|
||||||
import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
|
import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
|
||||||
import AuthTokenValidation from '/imports/api/auth-token-validation';
|
|
||||||
import Breakouts from '/imports/api/breakouts';
|
import Breakouts from '/imports/api/breakouts';
|
||||||
import Meetings, {
|
import Meetings, {
|
||||||
RecordMeetings, MeetingTimeRemaining, Notifications,
|
MeetingTimeRemaining, Notifications,
|
||||||
} from '/imports/api/meetings';
|
} from '/imports/api/meetings';
|
||||||
import Users from '/imports/api/users';
|
import Users from '/imports/api/users';
|
||||||
|
|
||||||
@ -31,8 +30,6 @@ export const localCollectionRegistry = {
|
|||||||
localPadsSync: new AbstractCollection(Pads, Pads),
|
localPadsSync: new AbstractCollection(Pads, Pads),
|
||||||
localPadsSessionsSync: new AbstractCollection(PadsSessions, PadsSessions),
|
localPadsSessionsSync: new AbstractCollection(PadsSessions, PadsSessions),
|
||||||
localPadsUpdatesSync: new AbstractCollection(PadsUpdates, PadsUpdates),
|
localPadsUpdatesSync: new AbstractCollection(PadsUpdates, PadsUpdates),
|
||||||
localAuthTokenValidationSync: new AbstractCollection(AuthTokenValidation, AuthTokenValidation),
|
|
||||||
localRecordMeetingsSync: new AbstractCollection(RecordMeetings, RecordMeetings),
|
|
||||||
localMeetingTimeRemainingSync: new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining),
|
localMeetingTimeRemainingSync: new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining),
|
||||||
localBreakoutsSync: new AbstractCollection(Breakouts, Breakouts),
|
localBreakoutsSync: new AbstractCollection(Breakouts, Breakouts),
|
||||||
localMeetingsSync: new AbstractCollection(Meetings, Meetings),
|
localMeetingsSync: new AbstractCollection(Meetings, Meetings),
|
||||||
|
@ -23,7 +23,6 @@ import logger from '/imports/startup/client/logger';
|
|||||||
import '/imports/ui/services/mobile-app';
|
import '/imports/ui/services/mobile-app';
|
||||||
import Base from '/imports/startup/client/base';
|
import Base from '/imports/startup/client/base';
|
||||||
import JoinHandler from '../imports/ui/components/join-handler/component';
|
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 Subscriptions from '/imports/ui/components/subscriptions/component';
|
||||||
import IntlStartup from '/imports/startup/client/intl';
|
import IntlStartup from '/imports/startup/client/intl';
|
||||||
import ContextProviders from '/imports/ui/components/context-providers/component';
|
import ContextProviders from '/imports/ui/components/context-providers/component';
|
||||||
|
@ -11,6 +11,10 @@ const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings
|
|||||||
.application.microphoneConstraints;
|
.application.microphoneConstraints;
|
||||||
const MEDIA_TAG = Meteor.settings.public.media.mediaTag;
|
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 = () => {
|
const getAudioSessionNumber = () => {
|
||||||
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
|
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
|
||||||
if (!currItem) {
|
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 {
|
export {
|
||||||
DEFAULT_INPUT_DEVICE_ID,
|
DEFAULT_INPUT_DEVICE_ID,
|
||||||
DEFAULT_OUTPUT_DEVICE_ID,
|
DEFAULT_OUTPUT_DEVICE_ID,
|
||||||
@ -131,4 +150,5 @@ export {
|
|||||||
getStoredAudioOutputDeviceId,
|
getStoredAudioOutputDeviceId,
|
||||||
storeAudioOutputDeviceId,
|
storeAudioOutputDeviceId,
|
||||||
doGUM,
|
doGUM,
|
||||||
|
stereoUnsupported,
|
||||||
};
|
};
|
||||||
|
@ -20,8 +20,8 @@ import {
|
|||||||
getAudioConstraints,
|
getAudioConstraints,
|
||||||
filterSupportedConstraints,
|
filterSupportedConstraints,
|
||||||
doGUM,
|
doGUM,
|
||||||
|
stereoUnsupported,
|
||||||
} from '/imports/api/audio/client/bridge/service';
|
} 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 = Meteor.settings.public.media;
|
||||||
const MEDIA_TAG = MEDIA.mediaTag;
|
const MEDIA_TAG = MEDIA.mediaTag;
|
||||||
@ -717,7 +717,7 @@ class SIPSession {
|
|||||||
// via SDP munging. Having it disabled on server side FS _does not suffice_
|
// via SDP munging. Having it disabled on server side FS _does not suffice_
|
||||||
// because the stereo parameter is client-mandated (ie replicated in the
|
// because the stereo parameter is client-mandated (ie replicated in the
|
||||||
// answer)
|
// answer)
|
||||||
if (SpeechService.stereoUnsupported()) {
|
if (stereoUnsupported()) {
|
||||||
logger.debug({
|
logger.debug({
|
||||||
logCode: 'sipjs_transcription_disable_stereo',
|
logCode: 'sipjs_transcription_disable_stereo',
|
||||||
}, 'Transcription provider does not support stereo, forcing stereo=0');
|
}, '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 Meetings = new Mongo.Collection('meetings', collectionOptions);
|
||||||
const RecordMeetings = new Mongo.Collection('record-meetings', collectionOptions);
|
|
||||||
const MeetingTimeRemaining = new Mongo.Collection('meeting-time-remaining', collectionOptions);
|
const MeetingTimeRemaining = new Mongo.Collection('meeting-time-remaining', collectionOptions);
|
||||||
const Notifications = new Mongo.Collection('notifications', collectionOptions);
|
const Notifications = new Mongo.Collection('notifications', collectionOptions);
|
||||||
const LayoutMeetings = new Mongo.Collection('layout-meetings');
|
const LayoutMeetings = new Mongo.Collection('layout-meetings');
|
||||||
@ -15,13 +14,11 @@ if (Meteor.isServer) {
|
|||||||
// 1. meetingId
|
// 1. meetingId
|
||||||
|
|
||||||
Meetings.createIndexAsync({ meetingId: 1 });
|
Meetings.createIndexAsync({ meetingId: 1 });
|
||||||
RecordMeetings.createIndexAsync({ meetingId: 1 });
|
|
||||||
MeetingTimeRemaining.createIndexAsync({ meetingId: 1 });
|
MeetingTimeRemaining.createIndexAsync({ meetingId: 1 });
|
||||||
LayoutMeetings.createIndexAsync({ meetingId: 1 });
|
LayoutMeetings.createIndexAsync({ meetingId: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RecordMeetings,
|
|
||||||
MeetingTimeRemaining,
|
MeetingTimeRemaining,
|
||||||
Notifications,
|
Notifications,
|
||||||
LayoutMeetings,
|
LayoutMeetings,
|
||||||
|
@ -7,8 +7,6 @@ import handleMeetingLocksChange from './handlers/meetingLockChange';
|
|||||||
import handleGuestPolicyChanged from './handlers/guestPolicyChanged';
|
import handleGuestPolicyChanged from './handlers/guestPolicyChanged';
|
||||||
import handleGuestLobbyMessageChanged from './handlers/guestLobbyMessageChanged';
|
import handleGuestLobbyMessageChanged from './handlers/guestLobbyMessageChanged';
|
||||||
import handleUserLockChange from './handlers/userLockChange';
|
import handleUserLockChange from './handlers/userLockChange';
|
||||||
import handleRecordingStatusChange from './handlers/recordingStatusChange';
|
|
||||||
import handleRecordingTimerChange from './handlers/recordingTimerChange';
|
|
||||||
import handleTimeRemainingUpdate from './handlers/timeRemainingUpdate';
|
import handleTimeRemainingUpdate from './handlers/timeRemainingUpdate';
|
||||||
import handleChangeWebcamOnlyModerator from './handlers/webcamOnlyModerator';
|
import handleChangeWebcamOnlyModerator from './handlers/webcamOnlyModerator';
|
||||||
import handleBroadcastLayout from './handlers/broadcastLayout';
|
import handleBroadcastLayout from './handlers/broadcastLayout';
|
||||||
@ -23,8 +21,6 @@ RedisPubSub.on('MeetingEndingEvtMsg', handleMeetingEnd);
|
|||||||
RedisPubSub.on('MeetingDestroyedEvtMsg', handleMeetingDestruction);
|
RedisPubSub.on('MeetingDestroyedEvtMsg', handleMeetingDestruction);
|
||||||
RedisPubSub.on('LockSettingsInMeetingChangedEvtMsg', handleMeetingLocksChange);
|
RedisPubSub.on('LockSettingsInMeetingChangedEvtMsg', handleMeetingLocksChange);
|
||||||
RedisPubSub.on('UserLockedInMeetingEvtMsg', handleUserLockChange);
|
RedisPubSub.on('UserLockedInMeetingEvtMsg', handleUserLockChange);
|
||||||
RedisPubSub.on('RecordingStatusChangedEvtMsg', handleRecordingStatusChange);
|
|
||||||
RedisPubSub.on('UpdateRecordingTimerEvtMsg', handleRecordingTimerChange);
|
|
||||||
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
|
RedisPubSub.on('WebcamsOnlyForModeratorChangedEvtMsg', handleChangeWebcamOnlyModerator);
|
||||||
RedisPubSub.on('GetLockSettingsRespMsg', handleMeetingLocksChange);
|
RedisPubSub.on('GetLockSettingsRespMsg', handleMeetingLocksChange);
|
||||||
RedisPubSub.on('GuestPolicyChangedEvtMsg', handleGuestPolicyChanged);
|
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';
|
} from 'meteor/check';
|
||||||
import SanitizeHTML from 'sanitize-html';
|
import SanitizeHTML from 'sanitize-html';
|
||||||
import Meetings, {
|
import Meetings, {
|
||||||
RecordMeetings,
|
|
||||||
LayoutMeetings,
|
LayoutMeetings,
|
||||||
} from '/imports/api/meetings';
|
} from '/imports/api/meetings';
|
||||||
import Logger from '/imports/startup/server/logger';
|
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');
|
await addLayout(meetingId, LAYOUT_TYPE[meetingLayout] || 'smart');
|
||||||
|
|
||||||
try {
|
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 clearScreenshare from '/imports/api/screenshare/server/modifiers/clearScreenshare';
|
||||||
import clearTimer from '/imports/api/timer/server/modifiers/clearTimer';
|
import clearTimer from '/imports/api/timer/server/modifiers/clearTimer';
|
||||||
import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining';
|
import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining';
|
||||||
import clearRecordMeeting from './clearRecordMeeting';
|
|
||||||
import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
|
import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
|
||||||
import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation';
|
import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation';
|
||||||
import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions';
|
import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions';
|
||||||
@ -35,7 +34,6 @@ export default async function meetingHasEnded(meetingId) {
|
|||||||
clearUserInfo(meetingId),
|
clearUserInfo(meetingId),
|
||||||
clearTimer(meetingId),
|
clearTimer(meetingId),
|
||||||
clearMeetingTimeRemaining(meetingId),
|
clearMeetingTimeRemaining(meetingId),
|
||||||
clearRecordMeeting(meetingId),
|
|
||||||
clearVideoStreams(meetingId),
|
clearVideoStreams(meetingId),
|
||||||
clearAuthTokenValidation(meetingId),
|
clearAuthTokenValidation(meetingId),
|
||||||
clearWhiteboardMultiUser(meetingId),
|
clearWhiteboardMultiUser(meetingId),
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import { Random } from 'meteor/random';
|
import { Random } from 'meteor/random';
|
||||||
import Meetings, {
|
import Meetings, {
|
||||||
RecordMeetings,
|
|
||||||
MeetingTimeRemaining,
|
MeetingTimeRemaining,
|
||||||
LayoutMeetings,
|
LayoutMeetings,
|
||||||
} from '/imports/api/meetings';
|
} from '/imports/api/meetings';
|
||||||
@ -72,27 +71,6 @@ function publish(...args) {
|
|||||||
|
|
||||||
Meteor.publish('meetings', publish);
|
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() {
|
function layoutMeetings() {
|
||||||
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
|
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
|
||||||
|
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import validateAuthToken from './methods/validateAuthToken';
|
|
||||||
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
|
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
|
||||||
import userActivitySign from './methods/userActivitySign';
|
import userActivitySign from './methods/userActivitySign';
|
||||||
import validateConnection from './methods/validateConnection';
|
import validateConnection from './methods/validateConnection';
|
||||||
|
|
||||||
Meteor.methods({
|
Meteor.methods({
|
||||||
validateConnection,
|
validateConnection,
|
||||||
validateAuthToken,
|
|
||||||
setUserEffectiveConnectionType,
|
setUserEffectiveConnectionType,
|
||||||
userActivitySign,
|
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 Logger from './logger';
|
||||||
import userLeaving from '/imports/api/users/server/methods/userLeaving';
|
import userLeaving from '/imports/api/users/server/methods/userLeaving';
|
||||||
import { extractCredentials } from '/imports/api/common/server/helpers';
|
import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||||
import AuthTokenValidation from '/imports/api/auth-token-validation';
|
|
||||||
import Users from '/imports/api/users';
|
import Users from '/imports/api/users';
|
||||||
import { check } from 'meteor/check';
|
import { check } from 'meteor/check';
|
||||||
|
|
||||||
@ -140,13 +139,6 @@ class ClientConnections {
|
|||||||
|
|
||||||
Logger.debug(`Found ${activeConnections.length} active connections in server`);
|
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 onlineUsersId = onlineUsers.map(({ userId }) => userId);
|
||||||
|
|
||||||
const usersQuery = { userId: { $nin: onlineUsersId } };
|
const usersQuery = { userId: { $nin: onlineUsersId } };
|
||||||
|
@ -25,7 +25,10 @@ export interface Public {
|
|||||||
clientLog: ClientLog
|
clientLog: ClientLog
|
||||||
virtualBackgrounds: VirtualBackgrounds
|
virtualBackgrounds: VirtualBackgrounds
|
||||||
}
|
}
|
||||||
|
export interface Locales {
|
||||||
|
locale: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
export interface App {
|
export interface App {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
mobileFontSize: string
|
mobileFontSize: string
|
||||||
@ -453,6 +456,7 @@ export interface Captions {
|
|||||||
font: Font
|
font: Font
|
||||||
lines: number
|
lines: number
|
||||||
time: number
|
time: number
|
||||||
|
locales: Locales[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Font {
|
export interface Font {
|
||||||
|
@ -49,6 +49,29 @@ export interface Reaction {
|
|||||||
reactionEmoji: string;
|
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 {
|
export interface UserClientSettings {
|
||||||
userClientSettingsJson: string;
|
userClientSettingsJson: string;
|
||||||
}
|
}
|
||||||
@ -87,8 +110,8 @@ export interface User {
|
|||||||
isDialIn: boolean;
|
isDialIn: boolean;
|
||||||
voice?: Partial<Voice>;
|
voice?: Partial<Voice>;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
registeredAt: number;
|
registeredAt: string;
|
||||||
registeredOn: string;
|
registeredOn: number;
|
||||||
hasDrawPermissionOnCurrentPage: boolean;
|
hasDrawPermissionOnCurrentPage: boolean;
|
||||||
lastBreakoutRoom?: LastBreakoutRoom;
|
lastBreakoutRoom?: LastBreakoutRoom;
|
||||||
cameras: Array<Cameras>;
|
cameras: Array<Cameras>;
|
||||||
@ -99,6 +122,7 @@ export interface User {
|
|||||||
away: boolean;
|
away: boolean;
|
||||||
raiseHand: boolean;
|
raiseHand: boolean;
|
||||||
reaction: Reaction;
|
reaction: Reaction;
|
||||||
|
breakoutRooms: BreakoutRooms;
|
||||||
customParameters: Array<CustomParameter>;
|
customParameters: Array<CustomParameter>;
|
||||||
userClientSettings: UserClientSettings;
|
userClientSettings: UserClientSettings;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { PureComponent } from 'react';
|
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 { ActionsBarItemType, ActionsBarPosition } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/actions-bar-item/enums';
|
||||||
import Styled from './styles';
|
import Styled from './styles';
|
||||||
import ActionsDropdown from './actions-dropdown/container';
|
import ActionsDropdown from './actions-dropdown/container';
|
||||||
@ -111,6 +110,7 @@ class ActionsBar extends PureComponent {
|
|||||||
showPushLayout,
|
showPushLayout,
|
||||||
setPushLayout,
|
setPushLayout,
|
||||||
setPresentationFitToWidth,
|
setPresentationFitToWidth,
|
||||||
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { selectedLayout } = Settings.application;
|
const { selectedLayout } = Settings.application;
|
||||||
@ -151,11 +151,8 @@ class ActionsBar extends PureComponent {
|
|||||||
setPresentationFitToWidth,
|
setPresentationFitToWidth,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!deviceInfo.isMobile
|
|
||||||
? (
|
|
||||||
<AudioCaptionsButtonContainer />
|
<AudioCaptionsButtonContainer />
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</Styled.Left>
|
</Styled.Left>
|
||||||
<Styled.Center>
|
<Styled.Center>
|
||||||
{this.renderPluginsActionBarItems(ActionsBarPosition.LEFT)}
|
{this.renderPluginsActionBarItems(ActionsBarPosition.LEFT)}
|
||||||
|
@ -30,6 +30,7 @@ const ActionsBarContainer = (props) => {
|
|||||||
|
|
||||||
const { data: currentMeeting } = useMeeting((m) => ({
|
const { data: currentMeeting } = useMeeting((m) => ({
|
||||||
externalVideo: m.externalVideo,
|
externalVideo: m.externalVideo,
|
||||||
|
componentsFlags: m.componentsFlags,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isSharingVideo = !!currentMeeting?.externalVideo?.externalVideoUrl;
|
const isSharingVideo = !!currentMeeting?.externalVideo?.externalVideoUrl;
|
||||||
@ -56,6 +57,7 @@ const ActionsBarContainer = (props) => {
|
|||||||
const amIModerator = currentUserData?.isModerator;
|
const amIModerator = currentUserData?.isModerator;
|
||||||
|
|
||||||
if (actionsBarStyle.display === false) return null;
|
if (actionsBarStyle.display === false) return null;
|
||||||
|
if (!currentMeeting) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsBar {
|
<ActionsBar {
|
||||||
@ -70,14 +72,17 @@ const ActionsBarContainer = (props) => {
|
|||||||
isThereCurrentPresentation,
|
isThereCurrentPresentation,
|
||||||
isSharingVideo,
|
isSharingVideo,
|
||||||
stopExternalVideoShare,
|
stopExternalVideoShare,
|
||||||
|
isCaptionsAvailable: currentMeeting.componentsFlags.hasCaption,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RAISE_HAND_BUTTON_ENABLED = window.meetingClientSettings.public.app.raiseHandActionButton.enabled;
|
const RAISE_HAND_BUTTON_ENABLED = window.meetingClientSettings
|
||||||
const RAISE_HAND_BUTTON_CENTERED = window.meetingClientSettings.public.app.raiseHandActionButton.centered;
|
.public.app.raiseHandActionButton.enabled;
|
||||||
|
const RAISE_HAND_BUTTON_CENTERED = window.meetingClientSettings
|
||||||
|
.public.app.raiseHandActionButton.centered;
|
||||||
|
|
||||||
const isReactionsButtonEnabled = () => {
|
const isReactionsButtonEnabled = () => {
|
||||||
const USER_REACTIONS_ENABLED = window.meetingClientSettings.public.userReaction.enabled;
|
const USER_REACTIONS_ENABLED = window.meetingClientSettings.public.userReaction.enabled;
|
||||||
|
@ -30,7 +30,8 @@ const TimeSync: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && data) {
|
if (!loading && data) {
|
||||||
const time = new Date(data.current_time[0].currentTimestamp);
|
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]);
|
}, [data, loading]);
|
||||||
return null;
|
return null;
|
||||||
|
@ -18,7 +18,7 @@ import AudioContainer from '../audio/container';
|
|||||||
import BannerBarContainer from '/imports/ui/components/banner-bar/container';
|
import BannerBarContainer from '/imports/ui/components/banner-bar/container';
|
||||||
import RaiseHandNotifier from '/imports/ui/components/raisehand-notifier/container';
|
import RaiseHandNotifier from '/imports/ui/components/raisehand-notifier/container';
|
||||||
import ManyWebcamsNotifier from '/imports/ui/components/video-provider/many-users-notify/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 UploaderContainer from '/imports/ui/components/presentation/presentation-uploader/container';
|
||||||
import ScreenReaderAlertContainer from '../screenreader-alert/container';
|
import ScreenReaderAlertContainer from '../screenreader-alert/container';
|
||||||
import ScreenReaderAlertAdapter from '../screenreader-alert/adapter';
|
import ScreenReaderAlertAdapter from '../screenreader-alert/adapter';
|
||||||
@ -40,7 +40,7 @@ import SidebarContentContainer from '../sidebar-content/container';
|
|||||||
import PluginsEngineManager from '../plugins-engine/manager';
|
import PluginsEngineManager from '../plugins-engine/manager';
|
||||||
import Settings from '/imports/ui/services/settings';
|
import Settings from '/imports/ui/services/settings';
|
||||||
import { registerTitleView } from '/imports/utils/dom-utils';
|
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 GlobalStyles from '/imports/ui/stylesheets/styled-components/globalStyles';
|
||||||
import ActionsBarContainer from '../actions-bar/container';
|
import ActionsBarContainer from '../actions-bar/container';
|
||||||
import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine';
|
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 TimerService from '/imports/ui/components/timer/service';
|
||||||
import TimeSync from './app-graphql/time-sync/component';
|
import TimeSync from './app-graphql/time-sync/component';
|
||||||
import PresentationUploaderToastContainer from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/container';
|
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 FloatingWindowContainer from '/imports/ui/components/floating-window/container';
|
||||||
import ChatAlertContainerGraphql from '../chat/chat-graphql/alert/component';
|
import ChatAlertContainerGraphql from '../chat/chat-graphql/alert/component';
|
||||||
|
|
||||||
@ -565,6 +566,7 @@ setRandomUserSelectModalIsOpen(value) {
|
|||||||
intl,
|
intl,
|
||||||
isModerator,
|
isModerator,
|
||||||
genericComponentId,
|
genericComponentId,
|
||||||
|
speechLocale,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -635,13 +637,15 @@ setRandomUserSelectModalIsOpen(value) {
|
|||||||
{this.renderAudioCaptions()}
|
{this.renderAudioCaptions()}
|
||||||
<PresentationUploaderToastContainer intl={intl} />
|
<PresentationUploaderToastContainer intl={intl} />
|
||||||
<UploaderContainer />
|
<UploaderContainer />
|
||||||
<BreakoutRoomInvitation isModerator={isModerator} />
|
<BreakoutJoinConfirmationContainerGraphQL />
|
||||||
<AudioContainer {...{
|
<AudioContainer {...{
|
||||||
isAudioModalOpen,
|
isAudioModalOpen,
|
||||||
setAudioModalIsOpen: this.setAudioModalIsOpen,
|
setAudioModalIsOpen: this.setAudioModalIsOpen,
|
||||||
isVideoPreviewModalOpen,
|
isVideoPreviewModalOpen,
|
||||||
setVideoPreviewModalIsOpen: this.setVideoPreviewModalIsOpen,
|
setVideoPreviewModalIsOpen: this.setVideoPreviewModalIsOpen,
|
||||||
}} />
|
speechLocale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ToastContainer rtl />
|
<ToastContainer rtl />
|
||||||
{(audioAlertEnabled || pushAlertEnabled)
|
{(audioAlertEnabled || pushAlertEnabled)
|
||||||
&& (
|
&& (
|
||||||
|
@ -102,6 +102,7 @@ const AppContainer = (props) => {
|
|||||||
enforceLayout: user.enforceLayout,
|
enforceLayout: user.enforceLayout,
|
||||||
isModerator: user.isModerator,
|
isModerator: user.isModerator,
|
||||||
presenter: user.presenter,
|
presenter: user.presenter,
|
||||||
|
speechLocale: user.speechLocale,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isModerator = currentUserData?.isModerator;
|
const isModerator = currentUserData?.isModerator;
|
||||||
@ -185,6 +186,9 @@ const AppContainer = (props) => {
|
|||||||
const shouldShowPresentation = (!shouldShowScreenshare && !isSharedNotesPinned
|
const shouldShowPresentation = (!shouldShowScreenshare && !isSharedNotesPinned
|
||||||
&& !shouldShowExternalVideo && !shouldShowGenericComponent
|
&& !shouldShowExternalVideo && !shouldShowGenericComponent
|
||||||
&& (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled();
|
&& (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled();
|
||||||
|
|
||||||
|
if (!currentUserData) return null;
|
||||||
|
|
||||||
return currentUserId
|
return currentUserId
|
||||||
? (
|
? (
|
||||||
<App
|
<App
|
||||||
@ -222,6 +226,7 @@ const AppContainer = (props) => {
|
|||||||
isPresenter,
|
isPresenter,
|
||||||
numCameras: cameraDockInput.numCameras,
|
numCameras: cameraDockInput.numCameras,
|
||||||
enforceLayout: validateEnforceLayout(currentUserData),
|
enforceLayout: validateEnforceLayout(currentUserData),
|
||||||
|
speechLocale: currentUserData?.speechLocale,
|
||||||
isModerator,
|
isModerator,
|
||||||
shouldShowScreenshare,
|
shouldShowScreenshare,
|
||||||
isSharedNotesPinned,
|
isSharedNotesPinned,
|
||||||
@ -230,6 +235,7 @@ const AppContainer = (props) => {
|
|||||||
toggleVoice,
|
toggleVoice,
|
||||||
setLocalSettings,
|
setLocalSettings,
|
||||||
genericComponentId: genericComponent.genericComponentId,
|
genericComponentId: genericComponent.genericComponentId,
|
||||||
|
audioCaptions: <AudioCaptionsLiveContainer speechLocale={currentUserData?.speechLocale} />,
|
||||||
}}
|
}}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
|
@ -4,16 +4,21 @@ import { Layout } from '/imports/ui/components/layout/layoutTypes';
|
|||||||
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
|
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
|
||||||
import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
|
import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
|
||||||
import BBBMenu from '/imports/ui/components/common/menu/component';
|
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 Styled from './styles';
|
||||||
import {
|
import {
|
||||||
getSpeechVoices, isAudioTranscriptionEnabled, setAudioCaptions, setSpeechLocale,
|
setAudioCaptions, setSpeechLocale,
|
||||||
} from '../service';
|
} from '../service';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import { MenuSeparatorItemType, MenuOptionItemType } from '/imports/ui/components/common/menu/menuTypes';
|
import { MenuSeparatorItemType, MenuOptionItemType } from '/imports/ui/components/common/menu/menuTypes';
|
||||||
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
||||||
import { User } from '/imports/ui/Types/user';
|
import { User } from '/imports/ui/Types/user';
|
||||||
import { useMutation } from '@apollo/client';
|
|
||||||
import { SET_SPEECH_LOCALE } from '/imports/ui/core/graphql/mutations/userMutations';
|
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({
|
const intlMessages = defineMessages({
|
||||||
start: {
|
start: {
|
||||||
@ -89,7 +94,6 @@ interface AudioCaptionsButtonProps {
|
|||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
currentSpeechLocale: string;
|
currentSpeechLocale: string;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
isVoiceUser: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISABLED = '';
|
const DISABLED = '';
|
||||||
@ -99,8 +103,8 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
|
|||||||
currentSpeechLocale,
|
currentSpeechLocale,
|
||||||
availableVoices,
|
availableVoices,
|
||||||
isSupported,
|
isSupported,
|
||||||
isVoiceUser,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const knownLocales = window.meetingClientSettings.public.captions.locales;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [active] = useAudioCaptionEnable();
|
const [active] = useAudioCaptionEnable();
|
||||||
const [setSpeechLocaleMutation] = useMutation(SET_SPEECH_LOCALE);
|
const [setSpeechLocaleMutation] = useMutation(SET_SPEECH_LOCALE);
|
||||||
@ -127,11 +131,8 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
|
|||||||
if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
|
if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
|
||||||
}, [currentSpeechLocale]);
|
}, [currentSpeechLocale]);
|
||||||
|
|
||||||
const shouldRenderChevron = isSupported && isVoiceUser;
|
const shouldRenderChevron = isSupported;
|
||||||
|
const shouldRenderSelector = isSupported && availableVoices.length > 0;
|
||||||
const toggleTranscription = () => {
|
|
||||||
setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED, setUserSpeechLocale);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAvailableLocales = () => {
|
const getAvailableLocales = () => {
|
||||||
let indexToInsertSeparator = -1;
|
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',
|
key: 'availableLocalesList',
|
||||||
label: intl.formatMessage(intlMessages.language),
|
label: intl.formatMessage(intlMessages.language),
|
||||||
customStyles: Styled.TitleLabel,
|
customStyles: Styled.TitleLabel,
|
||||||
@ -183,22 +203,24 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
|
|||||||
{
|
{
|
||||||
key: 'separator-02',
|
key: 'separator-02',
|
||||||
isSeparator: true,
|
isSeparator: true,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// typed captions
|
||||||
|
return [{
|
||||||
|
key: 'availableLocalesList',
|
||||||
|
label: intl.formatMessage(intlMessages.language),
|
||||||
|
customStyles: Styled.TitleLabel,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
{
|
...getAvailableCaptions(),
|
||||||
key: 'transcriptionStatus',
|
];
|
||||||
label: intl.formatMessage(
|
};
|
||||||
isTranscriptionDisabled()
|
|
||||||
? intlMessages.transcriptionOn
|
|
||||||
: intlMessages.transcriptionOff,
|
|
||||||
),
|
|
||||||
customStyles: isTranscriptionDisabled()
|
|
||||||
? Styled.EnableTrascription : Styled.DisableTrascription,
|
|
||||||
disabled: false,
|
|
||||||
onClick: toggleTranscription,
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
const onToggleClick = (e: React.MouseEvent) => {
|
const onToggleClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!currentSpeechLocale && !active) {
|
||||||
|
setUserSpeechLocale(availableVoices[0], PROVIDER);
|
||||||
|
}
|
||||||
setAudioCaptions(!active);
|
setAudioCaptions(!active);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -216,7 +238,7 @@ const AudioCaptionsButton: React.FC<AudioCaptionsButtonProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
shouldRenderChevron
|
shouldRenderChevron || shouldRenderSelector
|
||||||
? (
|
? (
|
||||||
<Styled.SpanButtonWrapper>
|
<Styled.SpanButtonWrapper>
|
||||||
<BBBMenu
|
<BBBMenu
|
||||||
@ -261,15 +283,30 @@ const AudioCaptionsButtonContainer: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentUserLoading) return null;
|
const {
|
||||||
if (!currentUser) return null;
|
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 currentSpeechLocale = currentUser.speechLocale || '';
|
||||||
const isSupported = availableVoices.length > 0;
|
const isSupported = availableVoices.length > 0;
|
||||||
const isVoiceUser = !!currentUser.voice;
|
|
||||||
|
|
||||||
if (!isAudioTranscriptionEnabled()) return null;
|
if (!currentMeetingData.componentsFlags?.hasCaption) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AudioCaptionsButton
|
<AudioCaptionsButton
|
||||||
@ -277,7 +314,6 @@ const AudioCaptionsButtonContainer: React.FC = () => {
|
|||||||
availableVoices={availableVoices}
|
availableVoices={availableVoices}
|
||||||
currentSpeechLocale={currentSpeechLocale}
|
currentSpeechLocale={currentSpeechLocale}
|
||||||
isSupported={isSupported}
|
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`
|
export const GET_AUDIO_CAPTIONS_COUNT = gql`
|
||||||
subscription GetAudioCaptionsCount {
|
subscription GetAudioCaptionsCount {
|
||||||
caption_aggregate {
|
caption_aggregate {
|
||||||
@ -20,4 +34,5 @@ export const GET_AUDIO_CAPTIONS_COUNT = gql`
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
GET_AUDIO_CAPTIONS_COUNT,
|
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 { defineMessages, useIntl } from 'react-intl';
|
||||||
import { useMutation } from '@apollo/client';
|
import { useMutation } from '@apollo/client';
|
||||||
|
|
||||||
@ -144,6 +144,14 @@ const AudioCaptionsSelect: React.FC<AudioCaptionsSelectProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AudioCaptionsSelectContainer: React.FC = () => {
|
const AudioCaptionsSelectContainer: React.FC = () => {
|
||||||
|
const [voicesList, setVoicesList] = React.useState<string[]>([]);
|
||||||
|
const voices = getSpeechVoices();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (voices && voicesList.length === 0) {
|
||||||
|
setVoicesList(voices);
|
||||||
|
}
|
||||||
|
}, [voices]);
|
||||||
const {
|
const {
|
||||||
data: currentUser,
|
data: currentUser,
|
||||||
} = useCurrentUser(
|
} = useCurrentUser(
|
||||||
@ -153,15 +161,13 @@ const AudioCaptionsSelectContainer: React.FC = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const isEnabled = isAudioTranscriptionEnabled();
|
const isEnabled = isAudioTranscriptionEnabled();
|
||||||
const voices = getSpeechVoices();
|
|
||||||
|
|
||||||
if (!currentUser || !isEnabled || !voices) return null;
|
if (!currentUser || !isEnabled || !voices) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AudioCaptionsSelect
|
<AudioCaptionsSelect
|
||||||
isTranscriptionEnabled={isEnabled}
|
isTranscriptionEnabled={isEnabled}
|
||||||
speechLocale={currentUser.speechLocale ?? ''}
|
speechLocale={currentUser.speechLocale ?? ''}
|
||||||
speechVoices={voices}
|
speechVoices={voices || voicesList}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -5,6 +5,7 @@ import logger from '/imports/startup/client/logger';
|
|||||||
|
|
||||||
import Styled from './styles';
|
import Styled from './styles';
|
||||||
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
import useAudioCaptionEnable from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
||||||
|
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
|
||||||
|
|
||||||
interface AudioCaptionsLiveProps {
|
interface AudioCaptionsLiveProps {
|
||||||
captions: Caption[];
|
captions: Caption[];
|
||||||
@ -54,11 +55,19 @@ const AudioCaptionsLive: React.FC<AudioCaptionsLiveProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AudioCaptionsLiveContainer: React.FC = () => {
|
const AudioCaptionsLiveContainer: React.FC = () => {
|
||||||
|
const {
|
||||||
|
data: currentUser,
|
||||||
|
} = useCurrentUser((u) => ({
|
||||||
|
speechLocale: u.speechLocale,
|
||||||
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: AudioCaptionsLiveData,
|
data: AudioCaptionsLiveData,
|
||||||
loading: AudioCaptionsLiveLoading,
|
loading: AudioCaptionsLiveLoading,
|
||||||
error: AudioCaptionsLiveError,
|
error: AudioCaptionsLiveError,
|
||||||
} = useSubscription<getCaptions>(GET_CAPTIONS);
|
} = useSubscription<getCaptions>(GET_CAPTIONS, {
|
||||||
|
variables: { locale: currentUser?.speechLocale ?? 'en-US' },
|
||||||
|
});
|
||||||
|
|
||||||
const [audioCaptionsEnable] = useAudioCaptionEnable();
|
const [audioCaptionsEnable] = useAudioCaptionEnable();
|
||||||
|
|
||||||
|
@ -26,8 +26,8 @@ export interface GetAudioCaptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const GET_CAPTIONS = gql`
|
export const GET_CAPTIONS = gql`
|
||||||
subscription getCaptions {
|
subscription getCaptions($locale: String!) {
|
||||||
caption {
|
caption(where: {locale: {_eq: $locale}}) {
|
||||||
user {
|
user {
|
||||||
avatar
|
avatar
|
||||||
color
|
color
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { unique } from 'radash';
|
import { unique } from 'radash';
|
||||||
import logger from '/imports/startup/client/logger';
|
|
||||||
import { setAudioCaptionEnable } from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
import { setAudioCaptionEnable } from '/imports/ui/core/local-states/useAudioCaptionEnable';
|
||||||
import { isLiveTranscriptionEnabled } from '/imports/ui/services/features';
|
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) => {
|
export const setSpeechLocale = (value: string, setUserSpeechLocale: (a: string, b: string) => void) => {
|
||||||
const voices = getSpeechVoices();
|
|
||||||
|
|
||||||
if (voices.includes(value) || value === '') {
|
|
||||||
setUserSpeechLocale(value, CONFIG.provider);
|
setUserSpeechLocale(value, CONFIG.provider);
|
||||||
} else {
|
|
||||||
logger.error({
|
|
||||||
logCode: 'captions_speech_locale',
|
|
||||||
}, 'Captions speech set locale error');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFixedLocale = () => isAudioTranscriptionEnabled() && CONFIG.language.forceLocale;
|
export const useFixedLocale = () => isAudioTranscriptionEnabled() && CONFIG.language.forceLocale;
|
||||||
|
@ -12,8 +12,8 @@ import Help from '../help/component';
|
|||||||
import AudioDial from '../audio-dial/component';
|
import AudioDial from '../audio-dial/component';
|
||||||
import AudioAutoplayPrompt from '../autoplay/component';
|
import AudioAutoplayPrompt from '../autoplay/component';
|
||||||
import Settings from '/imports/ui/services/settings';
|
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 usePreviousValue from '/imports/ui/hooks/usePreviousValue';
|
||||||
|
import AudioCaptionsSelectContainer from '../audio-graphql/audio-captions/captions/component';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
intl: PropTypes.shape({
|
intl: PropTypes.shape({
|
||||||
@ -351,7 +351,7 @@ const AudioModal = (props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<CaptionsSelectContainer />
|
<AudioCaptionsSelectContainer />
|
||||||
</div>
|
</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(({
|
export default lockContextContainer(injectIntl(withTracker(({
|
||||||
intl, userLocks, isAudioModalOpen, setAudioModalIsOpen, setVideoPreviewModalIsOpen,
|
intl, userLocks, isAudioModalOpen, setAudioModalIsOpen, setVideoPreviewModalIsOpen,
|
||||||
|
speechLocale,
|
||||||
}) => {
|
}) => {
|
||||||
const { microphoneConstraints } = Settings.application;
|
const { microphoneConstraints } = Settings.application;
|
||||||
const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
|
const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
|
||||||
@ -239,7 +240,7 @@ export default lockContextContainer(injectIntl(withTracker(({
|
|||||||
setAudioModalIsOpen,
|
setAudioModalIsOpen,
|
||||||
microphoneConstraints,
|
microphoneConstraints,
|
||||||
init: async (toggleVoice) => {
|
init: async (toggleVoice) => {
|
||||||
await Service.init(messages, intl, toggleVoice);
|
await Service.init(messages, intl, toggleVoice, speechLocale);
|
||||||
if ((!autoJoin || didMountAutoJoin)) {
|
if ((!autoJoin || didMountAutoJoin)) {
|
||||||
if (enableVideo && autoShareWebcam) {
|
if (enableVideo && autoShareWebcam) {
|
||||||
openVideoPreviewModal();
|
openVideoPreviewModal();
|
||||||
|
@ -45,7 +45,7 @@ const audioEventHandler = (toggleVoice) => (event) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = (messages, intl, toggleVoice) => {
|
const init = (messages, intl, toggleVoice, speechLocale) => {
|
||||||
AudioManager.setAudioMessages(messages, intl);
|
AudioManager.setAudioMessages(messages, intl);
|
||||||
if (AudioManager.initialized) return Promise.resolve(false);
|
if (AudioManager.initialized) return Promise.resolve(false);
|
||||||
const meetingId = Auth.meetingID;
|
const meetingId = Auth.meetingID;
|
||||||
@ -66,6 +66,7 @@ const init = (messages, intl, toggleVoice) => {
|
|||||||
username,
|
username,
|
||||||
voiceBridge,
|
voiceBridge,
|
||||||
microphoneLockEnforced,
|
microphoneLockEnforced,
|
||||||
|
speechLocale,
|
||||||
};
|
};
|
||||||
|
|
||||||
return AudioManager.init(userData, audioEventHandler(toggleVoice));
|
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 {
|
interface CreateBreakoutRoomContainerProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
priority: number,
|
priority: string,
|
||||||
isUpdate?: boolean,
|
isUpdate?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
import logger from '/imports/startup/client/logger';
|
import logger from '/imports/startup/client/logger';
|
||||||
import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations';
|
import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations';
|
||||||
import useToggleVoice from '../audio/audio-graphql/hooks/useToggleVoice';
|
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 BreakoutContainer = (props) => {
|
||||||
const layoutContextDispatch = layoutDispatch();
|
const layoutContextDispatch = layoutDispatch();
|
||||||
@ -92,15 +93,7 @@ const BreakoutContainer = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <BreakoutComponent
|
return <BreakoutContainerGraphql />;
|
||||||
amIPresenter={amIPresenter}
|
|
||||||
endAllBreakouts={endAllBreakouts}
|
|
||||||
setBreakoutsTime={setBreakoutsTime}
|
|
||||||
transferUserToMeeting={transferUserToMeeting}
|
|
||||||
requestJoinURL={requestJoinURL}
|
|
||||||
sendUserUnshareWebcam={sendUserUnshareWebcam}
|
|
||||||
{...{ layoutContextDispatch, isRTL, amIModerator, rejoinAudio, ...props }}
|
|
||||||
/>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withTracker((props) => {
|
export default withTracker((props) => {
|
||||||
|
@ -4,7 +4,6 @@ import { withTracker } from 'meteor/react-meteor-data';
|
|||||||
import deviceInfo from '/imports/utils/deviceInfo';
|
import deviceInfo from '/imports/utils/deviceInfo';
|
||||||
import browserInfo from '/imports/utils/browserInfo';
|
import browserInfo from '/imports/utils/browserInfo';
|
||||||
import OptionsDropdown from './component';
|
import OptionsDropdown from './component';
|
||||||
import audioCaptionsService from '/imports/ui/components/audio/captions/service';
|
|
||||||
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
|
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
|
||||||
import { meetingIsBreakout } from '/imports/ui/components/app/service';
|
import { meetingIsBreakout } from '/imports/ui/components/app/service';
|
||||||
import { layoutSelectInput, layoutSelect } from '../../layout/context';
|
import { layoutSelectInput, layoutSelect } from '../../layout/context';
|
||||||
@ -18,6 +17,9 @@ const { isIphone } = deviceInfo;
|
|||||||
const { isSafari, isValidSafariVersion } = browserInfo;
|
const { isSafari, isValidSafariVersion } = browserInfo;
|
||||||
|
|
||||||
const noIOSFullscreen = !!(((isSafari && !isValidSafariVersion) || isIphone));
|
const noIOSFullscreen = !!(((isSafari && !isValidSafariVersion) || isIphone));
|
||||||
|
const getAudioCaptions = () => Session.get('audioCaptions') || false;
|
||||||
|
|
||||||
|
const setAudioCaptions = (value) => Session.set('audioCaptions', value);
|
||||||
|
|
||||||
const OptionsDropdownContainer = (props) => {
|
const OptionsDropdownContainer = (props) => {
|
||||||
const { width: browserWidth } = layoutSelectInput((i) => i.browser);
|
const { width: browserWidth } = layoutSelectInput((i) => i.browser);
|
||||||
@ -56,8 +58,8 @@ export default withTracker((props) => {
|
|||||||
const handleToggleFullscreen = () => FullscreenService.toggleFullScreen();
|
const handleToggleFullscreen = () => FullscreenService.toggleFullScreen();
|
||||||
return {
|
return {
|
||||||
amIModerator: props.amIModerator,
|
amIModerator: props.amIModerator,
|
||||||
audioCaptionsActive: audioCaptionsService.getAudioCaptions(),
|
audioCaptionsActive: getAudioCaptions(),
|
||||||
audioCaptionsSet: (value) => audioCaptionsService.setAudioCaptions(value),
|
audioCaptionsSet: (value) => setAudioCaptions(value),
|
||||||
isMobile: deviceInfo.isMobile,
|
isMobile: deviceInfo.isMobile,
|
||||||
handleToggleFullscreen,
|
handleToggleFullscreen,
|
||||||
noIOSFullscreen,
|
noIOSFullscreen,
|
||||||
|
@ -152,20 +152,15 @@ export default injectIntl(withTracker(({ intl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meetingId = Auth.meetingID;
|
const meetingId = Auth.meetingID;
|
||||||
const breakouts = breakoutService.getBreakouts();
|
|
||||||
|
|
||||||
if (breakouts.length > 0) {
|
const Meeting = Meetings.findOne({ meetingId },
|
||||||
const currentBreakout = breakouts.find((b) => b.breakoutId === meetingId);
|
{ fields: { isBreakout: 1, componentsFlags: 1 } });
|
||||||
|
|
||||||
if (currentBreakout) {
|
if (Meeting.isBreakout) {
|
||||||
data.message = (
|
data.message = (
|
||||||
<MeetingRemainingTime />
|
<MeetingRemainingTime />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const Meeting = Meetings.findOne({ meetingId },
|
|
||||||
{ fields: { isBreakout: 1, componentsFlags: 1 } });
|
|
||||||
|
|
||||||
if (Meeting) {
|
if (Meeting) {
|
||||||
const { isBreakout, componentsFlags } = 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;
|
let title;
|
||||||
|
|
||||||
if (!recordingStatus) {
|
if (!recordingStatus) {
|
||||||
title = recordingTime >= 0 ? intl.formatMessage(intlMessages.resumeTitle)
|
title = recordingTime > 0 ? intl.formatMessage(intlMessages.resumeTitle)
|
||||||
: intl.formatMessage(intlMessages.startTitle);
|
: intl.formatMessage(intlMessages.startTitle);
|
||||||
} else {
|
} else {
|
||||||
title = intl.formatMessage(intlMessages.stopTitle);
|
title = intl.formatMessage(intlMessages.stopTitle);
|
||||||
|
@ -1,30 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withTracker } from 'meteor/react-meteor-data';
|
import { withTracker } from 'meteor/react-meteor-data';
|
||||||
import { RecordMeetings } from '/imports/api/meetings';
|
import { useMutation, useSubscription } from '@apollo/client';
|
||||||
import Auth from '/imports/ui/services/auth';
|
|
||||||
import { useMutation } from '@apollo/client';
|
|
||||||
import RecordingComponent from './component';
|
import RecordingComponent from './component';
|
||||||
import { SET_RECORDING_STATUS } from './mutations';
|
import { SET_RECORDING_STATUS } from './mutations';
|
||||||
|
import { GET_RECORDINGS } from './queries';
|
||||||
|
|
||||||
const RecordingContainer = (props) => <RecordingComponent {...props} />;
|
const RecordingContainer = (props) => {
|
||||||
|
const { setIsOpen } = props;
|
||||||
export default withTracker(({ setIsOpen }) => {
|
|
||||||
const { recording, time } = RecordMeetings.findOne({ meetingId: Auth.meetingID });
|
|
||||||
|
|
||||||
const [setRecordingStatus] = useMutation(SET_RECORDING_STATUS);
|
const [setRecordingStatus] = useMutation(SET_RECORDING_STATUS);
|
||||||
|
const {
|
||||||
|
data: recordingData,
|
||||||
|
} = useSubscription(GET_RECORDINGS);
|
||||||
|
|
||||||
return ({
|
const recording = recordingData?.meeting_recording[0]?.isRecording ?? false;
|
||||||
toggleRecording: () => {
|
const time = recordingData?.meeting_recording[0]?.previousRecordedTimeInSeconds ?? 0;
|
||||||
|
|
||||||
|
const toggleRecording = () => {
|
||||||
setRecordingStatus({
|
setRecordingStatus({
|
||||||
variables: {
|
variables: {
|
||||||
recording: !recording,
|
recording: !recording,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
},
|
};
|
||||||
|
return (
|
||||||
|
<RecordingComponent {
|
||||||
|
...{
|
||||||
recordingStatus: recording,
|
recordingStatus: recording,
|
||||||
recordingTime: time,
|
recordingTime: time,
|
||||||
isMeteorConnected: Meteor.status().connected,
|
toggleRecording,
|
||||||
|
...props,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
});
|
export default withTracker(({ setIsOpen }) => ({
|
||||||
})(RecordingContainer);
|
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 NotesContainer from '/imports/ui/components/notes/container';
|
||||||
import PollContainer from '/imports/ui/components/poll/container';
|
import PollContainer from '/imports/ui/components/poll/container';
|
||||||
import BreakoutRoomContainer from '/imports/ui/components/breakout-room/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 GuestUsersManagementPanel from '/imports/ui/components/waiting-users/waiting-users-graphql/component';
|
||||||
import Styled from './styles';
|
import Styled from './styles';
|
||||||
import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';
|
import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';
|
||||||
|
@ -20,15 +20,15 @@ const SUBSCRIPTIONS = [
|
|||||||
'users-settings',
|
'users-settings',
|
||||||
'users-infos',
|
'users-infos',
|
||||||
'meeting-time-remaining',
|
'meeting-time-remaining',
|
||||||
'record-meetings',
|
// 'record-meetings',
|
||||||
'video-streams',
|
'video-streams',
|
||||||
// 'voice-call-states',
|
// 'voice-call-states',
|
||||||
'breakouts',
|
// 'breakouts',
|
||||||
// 'breakouts-history',
|
// 'breakouts-history',
|
||||||
'pads',
|
'pads',
|
||||||
'pads-sessions',
|
'pads-sessions',
|
||||||
'pads-updates',
|
'pads-updates',
|
||||||
'notifications',
|
// 'notifications',
|
||||||
'layout-meetings',
|
'layout-meetings',
|
||||||
'user-reaction',
|
'user-reaction',
|
||||||
'timer',
|
'timer',
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useSubscription, useMutation } from '@apollo/client';
|
import { useSubscription, useMutation } from '@apollo/client';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 logger from '/imports/startup/client/logger';
|
||||||
import Styled from './styles';
|
import Styled from './styles';
|
||||||
import Icon from '/imports/ui/components/common/icon/icon-ts/component';
|
import Icon from '/imports/ui/components/common/icon/icon-ts/component';
|
||||||
@ -83,6 +83,10 @@ const TimerIndicator: React.FC<TimerIndicatorProps> = ({
|
|||||||
};
|
};
|
||||||
}, [songTrack]);
|
}, [songTrack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTime(passedTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alarm.current = new Audio(`${HOST}/resources/sounds/alarm.mp3`);
|
alarm.current = new Audio(`${HOST}/resources/sounds/alarm.mp3`);
|
||||||
}, []);
|
}, []);
|
||||||
@ -222,8 +226,7 @@ const TimerIndicatorContainer: React.FC = () => {
|
|||||||
const timePassed = stopwatch ? (
|
const timePassed = stopwatch ? (
|
||||||
Math.floor(((running ? timeDifferenceMs : 0) + accumulated))
|
Math.floor(((running ? timeDifferenceMs : 0) + accumulated))
|
||||||
) : (
|
) : (
|
||||||
Math.floor(((time) - accumulated))
|
Math.floor(((time) - (accumulated + (running ? timeDifferenceMs : 0)))));
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimerIndicator
|
<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';
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
export interface GetTimerResponse {
|
export interface TimerData {
|
||||||
timer: Array<{
|
|
||||||
accumulated: number;
|
accumulated: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
songTrack: string;
|
songTrack: string;
|
||||||
@ -10,7 +9,11 @@ export interface GetTimerResponse {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
startedOn: number;
|
startedOn: number;
|
||||||
endedOn: number;
|
endedOn: number;
|
||||||
}>;
|
startedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetTimerResponse {
|
||||||
|
timer: Array<TimerData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET_TIMER = gql`
|
export const GET_TIMER = gql`
|
||||||
@ -23,6 +26,7 @@ export const GET_TIMER = gql`
|
|||||||
stopwatch
|
stopwatch
|
||||||
running
|
running
|
||||||
startedOn
|
startedOn
|
||||||
|
startedAt
|
||||||
endedOn
|
endedOn
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,35 +1,70 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withTracker } from 'meteor/react-meteor-data';
|
|
||||||
import BreakoutRoomItem from './component';
|
import BreakoutRoomItem from './component';
|
||||||
import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
|
import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
|
||||||
import Breakouts from '/imports/api/breakouts';
|
import useMeeting from '/imports/ui/core/hooks/useMeeting';
|
||||||
import Auth from '/imports/ui/services/auth';
|
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 BreakoutRoomContainer = ({ breakoutRoom }) => {
|
||||||
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
|
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
|
||||||
const { sidebarContentPanel } = sidebarContent;
|
const { sidebarContentPanel } = sidebarContent;
|
||||||
const layoutContextDispatch = layoutDispatch();
|
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 (
|
return (
|
||||||
<BreakoutRoomItem {...{
|
<BreakoutRoomItem {...{
|
||||||
layoutContextDispatch,
|
layoutContextDispatch,
|
||||||
sidebarContentPanel,
|
sidebarContentPanel,
|
||||||
hasBreakoutRoom,
|
hasBreakoutRoom: hasBreakoutRoom
|
||||||
|
&& (userIsInvitedData.breakoutRoom.length > 0 || currentUser.isModerator),
|
||||||
breakoutRoom,
|
breakoutRoom,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withTracker(() => {
|
export default BreakoutRoomContainer;
|
||||||
const breakoutRoom = Breakouts.findOne(
|
|
||||||
{ parentMeetingId: Auth.meetingID },
|
|
||||||
{ fields: { timeRemaining: 1 } },
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
breakoutRoom,
|
|
||||||
};
|
|
||||||
})(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 publishCursorUpdate = (payload) => {
|
||||||
const { whiteboardId, xPercent, yPercent } = payload;
|
const { whiteboardId, xPercent, yPercent } = payload;
|
||||||
|
|
||||||
|
if (!whiteboardId || !xPercent || !yPercent) return;
|
||||||
|
|
||||||
presentationPublishCursor({
|
presentationPublishCursor({
|
||||||
variables: {
|
variables: {
|
||||||
whiteboardId,
|
whiteboardId,
|
||||||
|
@ -48,6 +48,29 @@ subscription userCurrentSubscription {
|
|||||||
parameter
|
parameter
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
breakoutRooms {
|
||||||
|
currentRoomJoined
|
||||||
|
assignedAt
|
||||||
|
breakoutRoomId
|
||||||
|
currentRoomPriority
|
||||||
|
currentRoomRegisteredAt
|
||||||
|
durationInSeconds
|
||||||
|
endedAt
|
||||||
|
freeJoin
|
||||||
|
inviteDismissedAt
|
||||||
|
isDefaultName
|
||||||
|
joinURL
|
||||||
|
lastRoomIsOnline
|
||||||
|
lastRoomJoinedAt
|
||||||
|
lastRoomJoinedId
|
||||||
|
name
|
||||||
|
sendInvitationToModerators
|
||||||
|
sequence
|
||||||
|
shortName
|
||||||
|
showInvitation
|
||||||
|
startedAt
|
||||||
|
currentRoomIsOnline
|
||||||
|
}
|
||||||
lastBreakoutRoom {
|
lastBreakoutRoom {
|
||||||
breakoutRoomId
|
breakoutRoomId
|
||||||
currentlyInRoom
|
currentlyInRoom
|
||||||
|
@ -2,7 +2,7 @@ import { useContext } from 'react';
|
|||||||
import { User } from '../../Types/user';
|
import { User } from '../../Types/user';
|
||||||
import { CurrentUserContext } from '../providers/current-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 response = useContext(CurrentUserContext);
|
||||||
const returnObject = {
|
const returnObject = {
|
||||||
...response,
|
...response,
|
||||||
|
@ -482,6 +482,12 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = {
|
|||||||
family: 'Calibri',
|
family: 'Calibri',
|
||||||
size: '24px',
|
size: '24px',
|
||||||
},
|
},
|
||||||
|
locales: [
|
||||||
|
{
|
||||||
|
locale: 'en-US',
|
||||||
|
name: 'English',
|
||||||
|
},
|
||||||
|
],
|
||||||
lines: 2,
|
lines: 2,
|
||||||
time: 5000,
|
time: 5000,
|
||||||
},
|
},
|
||||||
|
@ -587,6 +587,8 @@ public:
|
|||||||
name: "Ελληνικά"
|
name: "Ελληνικά"
|
||||||
- locale: "en"
|
- locale: "en"
|
||||||
name: "English"
|
name: "English"
|
||||||
|
- locale: "en-US"
|
||||||
|
name: "English"
|
||||||
- locale: "eo"
|
- locale: "eo"
|
||||||
name: "Esperanto"
|
name: "Esperanto"
|
||||||
- locale: "es"
|
- locale: "es"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"app.chat.submitLabel": "Send message",
|
"app.chat.submitLabel": "Send message",
|
||||||
"app.chat.loading": "Chat messages loaded: {0}%",
|
"app.chat.loading": "Chat messages loaded: {0}%",
|
||||||
"app.chat.errorMaxMessageLength": "The message is too long, exceeded the maximum of {0} characters",
|
"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.disconnected": "You are disconnected, messages can't be sent",
|
||||||
"app.chat.locked": "Chat is locked, messages can't be sent",
|
"app.chat.locked": "Chat is locked, messages can't be sent",
|
||||||
"app.chat.inputLabel": "Message input for chat {0}",
|
"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.modPage.waitAndClick(e.modalConfirmButton, ELEMENT_WAIT_LONGER_TIME);
|
||||||
|
|
||||||
await this.userPage.hasElement(e.modalConfirmButton);
|
await this.userPage.hasElement(e.modalConfirmButton);
|
||||||
await this.userPage.waitAndClick(e.modalDismissButton);
|
|
||||||
await this.modPage.hasElement(e.breakoutRoomsItem);
|
await this.modPage.hasElement(e.breakoutRoomsItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@ class Join extends Create {
|
|||||||
await breakoutUserPage.hasElement(e.presentationTitle);
|
await breakoutUserPage.hasElement(e.presentationTitle);
|
||||||
|
|
||||||
await this.modPage.waitAndClick(e.breakoutRoomsItem);
|
await this.modPage.waitAndClick(e.breakoutRoomsItem);
|
||||||
|
await this.modPage.hasElement(e.breakoutRemainingTime);
|
||||||
await this.modPage.type(e.chatBox, "test");
|
await this.modPage.type(e.chatBox, "test");
|
||||||
await this.modPage.waitAndClick(e.sendButton);
|
await this.modPage.waitAndClick(e.sendButton);
|
||||||
|
|
||||||
@ -234,6 +235,8 @@ class Join extends Create {
|
|||||||
async userCanChooseRoom() {
|
async userCanChooseRoom() {
|
||||||
await this.userPage.bringToFront();
|
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.checkElementCount(e.roomOption, 2);
|
||||||
|
|
||||||
await this.userPage.getLocator(`${e.fullscreenModal} >> select`).selectOption({index: 1});
|
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.timeRemaining = 'span[data-test="timeRemaining"]';
|
||||||
exports.captureBreakoutSharedNotes = 'input[id="captureNotesBreakoutCheckbox"]';
|
exports.captureBreakoutSharedNotes = 'input[id="captureNotesBreakoutCheckbox"]';
|
||||||
exports.captureBreakoutWhiteboard = 'input[id="captureSlidesBreakoutCheckbox"]';
|
exports.captureBreakoutWhiteboard = 'input[id="captureSlidesBreakoutCheckbox"]';
|
||||||
|
exports.selectBreakoutRoomBtn = 'select[data-test="selectBreakoutRoomBtn"]';
|
||||||
exports.roomOption = 'option[data-test="roomOption"]';
|
exports.roomOption = 'option[data-test="roomOption"]';
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
|
@ -314,6 +314,15 @@ class Page {
|
|||||||
async setHeightWidthViewPortSize() {
|
async setHeightWidthViewPortSize() {
|
||||||
await this.page.setViewportSize({ width: 1366, height: 768 });
|
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;
|
module.exports = exports = Page;
|
||||||
|
@ -72,8 +72,8 @@ class Presentation extends MultiUsers {
|
|||||||
await this.modPage.type(e.videoModalInput, e.youtubeLink);
|
await this.modPage.type(e.videoModalInput, e.youtubeLink);
|
||||||
await this.modPage.waitAndClick(e.startShareVideoBtn);
|
await this.modPage.waitAndClick(e.startShareVideoBtn);
|
||||||
|
|
||||||
const modFrame = await this.getFrame(this.modPage, e.youtubeFrame);
|
const modFrame = await this.modPage.getYoutubeFrame();
|
||||||
const userFrame = await this.getFrame(this.userPage, e.youtubeFrame);
|
const userFrame = await this.userPage.getYoutubeFrame();
|
||||||
|
|
||||||
await modFrame.hasElement('video');
|
await modFrame.hasElement('video');
|
||||||
await userFrame.hasElement('video');
|
await userFrame.hasElement('video');
|
||||||
@ -310,15 +310,6 @@ class Presentation extends MultiUsers {
|
|||||||
await this.userPage.wasRemoved(e.presentationsList);
|
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() {
|
async presentationFullscreen() {
|
||||||
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
|
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
|
||||||
const presentationLocator = this.modPage.getLocator(e.presentationContainer);
|
const presentationLocator = this.modPage.getLocator(e.presentationContainer);
|
||||||
|
@ -24,6 +24,33 @@ class ScreenShare extends Page {
|
|||||||
async testMobileDevice() {
|
async testMobileDevice() {
|
||||||
await this.wasRemoved(e.startScreenSharing);
|
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 {
|
class MultiUserScreenShare extends MultiUsers {
|
||||||
|
@ -13,6 +13,12 @@ test.describe.parallel('Screenshare', () => {
|
|||||||
await screenshare.startSharing();
|
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.describe.parallel('Mobile', () => {
|
||||||
test.beforeEach(({ browserName }) => {
|
test.beforeEach(({ browserName }) => {
|
||||||
test.skip(browserName === 'firefox', 'Mobile tests are not able in Firefox browser');
|
test.skip(browserName === 'firefox', 'Mobile tests are not able in Firefox browser');
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
const e = require('../core/elements');
|
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) {
|
async function startScreenshare(test) {
|
||||||
await test.waitAndClick(e.startScreenSharing);
|
await test.waitAndClick(e.startScreenSharing);
|
||||||
|
Loading…
Reference in New Issue
Block a user