Compare commits

...

20 Commits

Author SHA1 Message Date
Hristo Terezov
5e2ccf5534 feat(subtitles): Don't show delayed final
If a non final transcript was displayed and then hidden and then we receive a final transcript we remove the part that has already been shown before. If the final transcript is the same as the non final that was already displayed we don't show the final.
2024-12-06 10:56:48 -06:00
damencho
46c9c9f524 fix(visitors): Fixes slowing down after meeting becomes live.
Make sure there are several connects before slowing down the visitor trying to join. This slow down is handling the case where the meeting was live few minutes ago, but ended.
2024-12-03 13:32:27 -06:00
damencho
8cf16ca402 fix(visitors): Fixes go live check.
We need to check whether the live field is defined ignoring its boolean value. When it is false we were ignoring it and still marking the room as live.
2024-12-02 09:32:54 -06:00
Hristo Terezov
d3bc7e8399 fix(config-whitelist): remove props. 2024-11-25 15:33:24 -06:00
Hristo Terezov
1f7a4bcae3 feat(URL): Add metrics for config overwrites.
Add temporary metrics for interfaceConfig and config url overwrites.
2024-11-25 15:33:13 -06:00
Hristo Terezov
65a8282931 fix(supportURL): Override true dynamic branding only. 2024-11-25 15:32:58 -06:00
Horatiu Muresan
67241d7847 fix(config-whitelist) cleanup (#15314) 2024-11-25 15:32:20 -06:00
Andrei Gavrilescu
17a6d23d3c feat(premeeting): pre-join connection test (#15151)
* enable precall test

* minor fixes

* update lang sort

* code review
2024-11-18 15:26:35 -06:00
Hristo Terezov
a1ced791e2 feat(IFrameApi): setAudioOnly command & event. 2024-11-15 10:45:18 -06:00
damencho
edde9820c1 fix(connection): Detects tenant hyphen and length problems and show notification. 2024-11-15 09:57:20 -06:00
damencho
b70e4cfe56 fix(connection): Shows notification instead of reload on conference request failed. 2024-11-15 09:57:11 -06:00
damencho
b033c06ec2 fix(visitors): Fixes detection of turning of subtitles by visitor. 2024-11-07 11:13:22 -06:00
Hristo Terezov
abf06bc20e feat(metrics): related to conference.init execution.
Adds logs and analytics events with time measurements for the different stages of conference.init execution.
2024-11-01 10:06:17 -05:00
Дамян Минков
cd3f07b321 feat(connection): Handles the new connection error CONFERENCE_REQUEST_FAILED. (#15231)
* feat(connection): Handles the new connection error CONFERENCE_REQUEST_FAILED.

Reloads when this fatal error occurs.

* chore(deps) lib-jitsi-meet@latest

https://github.com/jitsi/lib-jitsi-meet/compare/v1878.0.0+a7476b12...v1880.0.0+5d53ecdc
2024-10-30 09:43:25 -05:00
Hristo Terezov
d950b70392 fix(conference): Make sure join waits for confernce.init.
It was possible that join can be executed before conference.init have even started or we haven't reached the point ot create the initialGUMPromise. This was causing the following issues:
 - users stuck on the prejoin screen
 - participants join 2+ times in the call (we have been creating more than 1 local participants from a single page).
2024-10-25 12:35:33 -05:00
damencho
1b0dc0a8d7 fix(transcribing): Fix missing transcribing status changed event. 2024-10-24 10:52:01 -05:00
damencho
4be62fb942 feat(subtitles): Adds an option to automatically enable subtitles when transcriber is available. 2024-10-22 12:42:23 -05:00
Saúl Ibarra Corretgé
681a66508e fix(chat) make reactions processing more resilient 2024-10-22 10:19:41 -05:00
Saúl Ibarra Corretgé
b76264fc4b fix(polls) add extra check when creating polls 2024-10-22 10:19:31 -05:00
damencho
0fadf0437a chore(deps) update lib-jitsi-meet.
Includes jitsi/lib-jitsi-meet@6782e6c and jitsi/lib-jitsi-meet@a72936d
2024-10-22 10:19:09 -05:00
49 changed files with 841 additions and 185 deletions

View File

@@ -136,6 +136,7 @@ import {
isUserInteractionRequiredForUnmute
} from './react/features/base/tracks/functions';
import { downloadJSON } from './react/features/base/util/downloadJSON';
import { getJitsiMeetGlobalNSConnectionTimes } from './react/features/base/util/helpers';
import { openLeaveReasonDialog } from './react/features/conference/actions.web';
import { showDesktopPicker } from './react/features/desktop-picker/actions';
import { appendSuffix } from './react/features/display-name/functions';
@@ -413,9 +414,10 @@ export default {
* without any audio tracks.
* @param {boolean} options.startWithVideoMuted - will start the conference
* without any video tracks.
* @param {boolean} recordTimeMetrics - If true time metrics will be recorded.
* @returns {Promise<JitsiLocalTrack[]>, Object}
*/
createInitialLocalTracks(options = {}) {
createInitialLocalTracks(options = {}, recordTimeMetrics = false) {
const errors = {};
// Always get a handle on the audio input device so that we have statistics (such as "No audio input" or
@@ -487,7 +489,7 @@ export default {
devices: initialDevices,
timeout,
firePermissionPromptIsShownEvent: true
})).then(({ tracks, errors: pErrors }) => {
}, recordTimeMetrics)).then(({ tracks, errors: pErrors }) => {
Object.assign(errors, pErrors);
return tracks;
@@ -560,10 +562,10 @@ export default {
* If prejoin page is enabled open an new connection in the background
* and create local tracks.
*
* @param {{ roomName: string }} options
* @param {{ roomName: string, shouldDispatchConnect }} options
* @returns {Promise}
*/
async init({ roomName }) {
async init({ roomName, shouldDispatchConnect }) {
const state = APP.store.getState();
const initialOptions = {
startAudioOnly: config.startAudioOnly,
@@ -571,8 +573,12 @@ export default {
startWithAudioMuted: getStartWithAudioMuted(state) || isUserInteractionRequiredForUnmute(state),
startWithVideoMuted: getStartWithVideoMuted(state) || isUserInteractionRequiredForUnmute(state)
};
const connectionTimes = getJitsiMeetGlobalNSConnectionTimes();
const startTime = window.performance.now();
logger.debug(`Executed conference.init with roomName: ${roomName}`);
connectionTimes['conference.init.start'] = startTime;
logger.debug(`Executed conference.init with roomName: ${roomName} (performance.now=${startTime})`);
this.roomName = roomName;
@@ -605,29 +611,64 @@ export default {
return localTracks;
};
const { dispatch, getState } = APP.store;
const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions);
const createLocalTracksStart = window.performance.now();
dispatch(setInitialGUMPromise(tryCreateLocalTracks.then(async tr => {
connectionTimes['conference.init.createLocalTracks.start'] = createLocalTracksStart;
logger.debug(`(TIME) createInitialLocalTracks: ${createLocalTracksStart} `);
const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions, true);
tryCreateLocalTracks.then(async tr => {
const createLocalTracksEnd = window.performance.now();
connectionTimes['conference.init.createLocalTracks.end'] = createLocalTracksEnd;
logger.debug(`(TIME) createInitialLocalTracks finished: ${createLocalTracksEnd} `);
const tracks = handleInitialTracks(initialOptions, tr);
this._initDeviceList(true);
const { initialGUMPromise } = getState()['features/base/media'];
if (isPrejoinPageVisible(getState())) {
dispatch(gumPending([ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE));
dispatch(setInitialGUMPromise());
// Since the conference is not yet created in redux this function will execute synchronous
// which will guarantee us that the local tracks are added to redux before we proceed.
initPrejoin(tracks, errors, dispatch);
connectionTimes['conference.init.end'] = window.performance.now();
// resolve the initialGUMPromise in case connect have finished so that we can proceed to join.
if (initialGUMPromise) {
logger.debug('Resolving the initialGUM promise! (prejoinVisible=true)');
initialGUMPromise.resolve({
tracks,
errors
});
}
logger.debug('Clear the initialGUM promise! (prejoinVisible=true)');
// For prejoin we don't need the initial GUM promise since the tracks are already added to the store
// via initPrejoin
dispatch(setInitialGUMPromise());
} else {
APP.store.dispatch(displayErrorsForCreateInitialLocalTracks(errors));
setGUMPendingStateOnFailedTracks(tracks, APP.store.dispatch);
connectionTimes['conference.init.end'] = window.performance.now();
if (initialGUMPromise) {
logger.debug('Resolving the initialGUM promise!');
initialGUMPromise.resolve({
tracks,
errors
});
}
}
});
return {
tracks,
errors
};
})));
if (!isPrejoinPageVisible(getState())) {
if (shouldDispatchConnect) {
logger.info('Dispatching connect from init since prejoin is not visible.');
dispatch(connect());
}
@@ -2047,8 +2088,9 @@ export default {
const { dispatch } = APP.store;
return dispatch(getAvailableDevices())
.then(devices => {
APP.UI.onAvailableDevicesChanged(devices);
.then(() => {
this.updateAudioIconEnabled();
this.updateVideoIconEnabled();
});
}
@@ -2213,7 +2255,8 @@ export default {
return Promise.all(promises)
.then(() => {
APP.UI.onAvailableDevicesChanged(filteredDevices);
this.updateAudioIconEnabled();
this.updateVideoIconEnabled();
});
},

View File

@@ -451,6 +451,10 @@ var config = {
// // Enables automatic turning on transcribing when recording is started
// autoTranscribeOnRecord: false,
// // Enables automatic request of subtitles when transcriber is present in the meeting, uses the default
// // language that is set
// autoCaptionOnTranscribe: false,
// },
// Misc
@@ -758,6 +762,11 @@ var config = {
// hideDisplayName: false,
// // List of buttons to hide from the extra join options dropdown.
// hideExtraJoinButtons: ['no-audio', 'by-phone'],
// // Configuration for pre-call test
// // By setting preCallTestEnabled, you enable the pre-call test in the prejoin page.
// // ICE server credentials need to be provided over the preCallTestICEUrl
// preCallTestEnabled: false,
// preCallTestICEUrl: ''
// },
// When 'true', the user cannot edit the display name.

View File

@@ -739,6 +739,7 @@
"connectedOneMember": "{{name}} joined the meeting",
"connectedThreePlusMembers": "{{name}} and many others joined the meeting",
"connectedTwoMembers": "{{first}} and {{second}} joined the meeting",
"connectionFailed": "Connection failed. Please try again later!",
"dataChannelClosed": "Video quality may be impaired",
"dataChannelClosedDescription": "The bridge channel is down and thus video quality may be limited to its lowest setting.",
"dataChannelClosedDescriptionWithAudio": "The bridge channel is down and thus disruptions to audio and video may occur.",
@@ -753,6 +754,9 @@
"gifsMenu": "GIPHY",
"groupTitle": "Notifications",
"hostAskedUnmute": "The moderator would like you to speak",
"invalidTenant": "Invalid tenant",
"invalidTenantHyphenDescription": "The tenant you are using is invalid (starts or ends with '-').",
"invalidTenantLengthDescription": "The tenant you are using is too long.",
"invitedOneMember": "{{name}} has been invited",
"invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
"invitedTwoMembers": "{{first}} and {{second}} have been invited",
@@ -922,9 +926,11 @@
"configuringDevices": "Configuring devices...",
"connectedWithAudioQ": "Youre connected with audio?",
"connection": {
"failed": "Connection test failed!",
"good": "Your internet connection looks good!",
"nonOptimal": "Your internet connection is not optimal",
"poor": "You have a poor internet connection"
"poor": "You have a poor internet connection",
"running": "Running connection test..."
},
"connectionDetails": {
"audioClipping": "We expect your audio to be clipped.",
@@ -933,6 +939,7 @@
"goodQuality": "Awesome! Your media quality is going to be great.",
"noMediaConnectivity": "We could not find a way to establish media connectivity for this test. This is typically caused by a firewall or NAT.",
"noVideo": "We expect that your video will be terrible.",
"testFailed": "The connection test encountered unexpected issues, but this might not impact your experience.",
"undetectable": "If you still can not make calls in browser, we recommend that you make sure your speakers, microphone and camera are properly set up, that you have granted your browser rights to use your microphone and camera, and that your browser version is up-to-date. If you still have trouble calling, you should contact the web application developer.",
"veryPoorConnection": "We expect your call quality to be really terrible.",
"videoFreezing": "We expect your video to freeze, turn black, and be pixelated.",

View File

@@ -14,6 +14,7 @@ import {
requestEnableVideoModeration
} from '../../react/features/av-moderation/actions';
import { isEnabledFromState } from '../../react/features/av-moderation/functions';
import { setAudioOnly } from '../../react/features/base/audio-only/actions';
import {
endConference,
sendTones,
@@ -565,6 +566,10 @@ function initCommands() {
sendAnalytics(createApiEvent('set.video.quality'));
APP.store.dispatch(setVideoQuality(frameHeight));
},
'set-audio-only': enable => {
sendAnalytics(createApiEvent('set.audio.only'));
APP.store.dispatch(setAudioOnly(enable));
},
'start-share-video': url => {
sendAnalytics(createApiEvent('share.video.start'));
const id = extractYoutubeIdOrURL(url);
@@ -2218,6 +2223,19 @@ class API {
});
}
/**
* Notify the external application (if API is enabled) when the audio only enabled status changed.
*
* @param {boolean} enabled - Whether the audio only is enabled or not.
* @returns {void}
*/
notifyAudioOnlyChanged(enabled) {
this._sendEvent({
name: 'audio-only-changed',
enabled
});
}
/**
* Disposes the allocated resources.
*

View File

@@ -59,6 +59,7 @@ const commands = {
sendEndpointTextMessage: 'send-endpoint-text-message',
sendParticipantToRoom: 'send-participant-to-room',
sendTones: 'send-tones',
setAudioOnly: 'set-audio-only',
setAssumedBandwidthBps: 'set-assumed-bandwidth-bps',
setBlurredBackground: 'set-blurred-background',
setFollowMe: 'set-follow-me',
@@ -103,6 +104,7 @@ const events = {
'avatar-changed': 'avatarChanged',
'audio-availability-changed': 'audioAvailabilityChanged',
'audio-mute-status-changed': 'audioMuteStatusChanged',
'audio-only-changed': 'audioOnlyChanged',
'audio-or-video-sharing-toggled': 'audioOrVideoSharingToggled',
'breakout-rooms-updated': 'breakoutRoomsUpdated',
'browser-support': 'browserSupport',

View File

@@ -211,14 +211,6 @@ UI.handleLastNEndpoints = function(leavingIds, enteringIds) {
*/
UI.setAudioLevel = (id, lvl) => VideoLayout.setAudioLevel(id, lvl);
/**
* Update list of available physical devices.
*/
UI.onAvailableDevicesChanged = function() {
APP.conference.updateAudioIconEnabled();
APP.conference.updateVideoIconEnabled();
};
/**
* Returns the id of the current video shown on large.
* Currently used by tests (torture).

View File

@@ -67,7 +67,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1876.0.0+fac989a9/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-8206",
"lodash-es": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",

View File

@@ -15,6 +15,7 @@ import { isAnalyticsEnabled } from '../base/lib-jitsi-meet/functions.any';
import { getJitsiMeetGlobalNS } from '../base/util/helpers';
import { inIframe } from '../base/util/iframeUtils';
import { loadScript } from '../base/util/loadScript';
import { parseURLParams } from '../base/util/parseURLParams';
import { parseURIString } from '../base/util/uri';
import { isPrejoinPageVisible } from '../prejoin/functions';
@@ -176,6 +177,7 @@ export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
const { group, server } = state['features/base/jwt'];
const { locationURL = { href: '' } } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
const params = parseURLParams(locationURL.href) ?? {};
const permanentProperties: {
appName?: string;
externalApi?: boolean;
@@ -183,6 +185,13 @@ export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
inIframe?: boolean;
isPromotedFromVisitor?: boolean;
isVisitor?: boolean;
overwritesDefaultLogoUrl?: boolean;
overwritesDeploymentUrls?: boolean;
overwritesLiveStreamingUrls?: boolean;
overwritesPeopleSearchUrl?: boolean;
overwritesPrejoinConfigICEUrl?: boolean;
overwritesSalesforceUrl?: boolean;
overwritesSupportUrl?: boolean;
server?: string;
tenant?: string;
wasLobbyVisible?: boolean;
@@ -221,6 +230,36 @@ export function initAnalytics(store: IStore, handlers: Array<Object>): boolean {
permanentProperties.isVisitor = false;
permanentProperties.isPromotedFromVisitor = false;
// TODO: Temporary metric. To be removed once we don't need it.
permanentProperties.overwritesSupportUrl = 'interfaceConfig.SUPPORT_URL' in params;
permanentProperties.overwritesSalesforceUrl = 'config.salesforceUrl' in params;
permanentProperties.overwritesPeopleSearchUrl = 'config.peopleSearchUrl' in params;
permanentProperties.overwritesDefaultLogoUrl = 'config.defaultLogoUrl' in params;
const prejoinConfig = params['config.prejoinConfig'] ?? {};
permanentProperties.overwritesPrejoinConfigICEUrl = ('config.prejoinConfig.preCallTestICEUrl' in params)
|| (typeof prejoinConfig === 'object' && 'preCallTestICEUrl' in prejoinConfig);
const deploymentUrlsConfig = params['config.deploymentUrls'] ?? {};
permanentProperties.overwritesDeploymentUrls
= 'config.deploymentUrls.downloadAppsUrl' in params || 'config.deploymentUrls.userDocumentationURL' in params
|| (typeof deploymentUrlsConfig === 'object'
&& ('downloadAppsUrl' in deploymentUrlsConfig || 'userDocumentationURL' in deploymentUrlsConfig));
const liveStreamingConfig = params['config.liveStreaming'] ?? {};
permanentProperties.overwritesLiveStreamingUrls
= ('interfaceConfig.LIVE_STREAMING_HELP_LINK' in params)
|| ('config.liveStreaming.termsLink' in params)
|| ('config.liveStreaming.dataPrivacyLink' in params)
|| ('config.liveStreaming.helpLink' in params)
|| (typeof params['config.liveStreaming'] === 'object' && 'config.liveStreaming' in params
&& (
'termsLink' in liveStreamingConfig
|| 'dataPrivacyLink' in liveStreamingConfig
|| 'helpLink' in liveStreamingConfig
)
);
// Optionally, include local deployment information based on the
// contents of window.config.deploymentInfo.
if (deploymentInfo) {

View File

@@ -79,7 +79,7 @@ export default class BaseApp<P> extends Component<P, IState> {
* @see {@link #_initStorage}
* @type {Promise}
*/
this._init = createDeferred();
this._init = createDeferred<void>();
try {
await this._initStorage();

View File

@@ -16,8 +16,9 @@ import { IStore } from '../../app/types';
import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { isVpaasMeeting } from '../../jaas/functions';
import { showErrorNotification } from '../../notifications/actions';
import { showErrorNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { INotificationProps } from '../../notifications/types';
import { hasDisplayName } from '../../prejoin/utils';
import { stopLocalVideoRecording } from '../../recording/actions.any';
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager';
@@ -37,6 +38,7 @@ import {
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { TRACK_ADDED, TRACK_REMOVED } from '../tracks/actionTypes';
import { parseURIString } from '../util/uri';
import {
CONFERENCE_FAILED,
@@ -419,6 +421,30 @@ function _connectionFailed({ dispatch, getState }: IStore, next: Function, actio
}
}
if (error.name === JitsiConnectionErrors.CONFERENCE_REQUEST_FAILED) {
let notificationAction: Function = showNotification;
const notificationProps = {
customActionNameKey: [ 'dialog.rejoinNow' ],
customActionHandler: [ () => dispatch(reloadNow()) ],
descriptionKey: 'notify.connectionFailed'
} as INotificationProps;
const { locationURL = { href: '' } as URL } = getState()['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
if (tenant.startsWith('-') || tenant.endsWith('-')) {
notificationProps.descriptionKey = 'notify.invalidTenantHyphenDescription';
notificationProps.titleKey = 'notify.invalidTenant';
notificationAction = showErrorNotification;
} else if (tenant.length > 63) {
notificationProps.descriptionKey = 'notify.invalidTenantLengthDescription';
notificationProps.titleKey = 'notify.invalidTenant';
notificationAction = showErrorNotification;
}
dispatch(notificationAction(notificationProps, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
const result = next(action);
_removeUnloadHandler(getState);
@@ -614,37 +640,6 @@ function _setRoom({ dispatch, getState }: IStore, next: Function, action: AnyAct
return next(action);
}
/**
* Synchronizes local tracks from state with local tracks in JitsiConference
* instance.
*
* @param {Store} store - The redux store.
* @param {Object} action - Action object.
* @private
* @returns {Promise}
*/
function _syncConferenceLocalTracksWithState({ getState }: IStore, action: AnyAction) {
const state = getState();
const conference = getCurrentConference(state);
let promise;
if (conference) {
const track = action.track.jitsiTrack;
if (action.type === TRACK_ADDED) {
// If gUM is slow and tracks are created after the user has already joined the conference, avoid
// adding the tracks to the conference if the user is a visitor.
if (!iAmVisitor(state)) {
promise = _addLocalTracksToConference(conference, [ track ]);
}
} else {
promise = _removeLocalTracksFromConference(conference, [ track ]);
}
}
return promise || Promise.resolve();
}
/**
* Notifies the feature base/conference that the action {@code TRACK_ADDED}
* or {@code TRACK_REMOVED} is being dispatched within a specific redux store.
@@ -664,9 +659,28 @@ function _trackAddedOrRemoved(store: IStore, next: Function, action: AnyAction)
// TODO All track swapping should happen here instead of conference.js.
if (track?.local) {
return (
_syncConferenceLocalTracksWithState(store, action)
.then(() => next(action)));
const { getState } = store;
const state = getState();
const conference = getCurrentConference(state);
let promise;
if (conference) {
const jitsiTrack = action.track.jitsiTrack;
if (action.type === TRACK_ADDED) {
// If gUM is slow and tracks are created after the user has already joined the conference, avoid
// adding the tracks to the conference if the user is a visitor.
if (!iAmVisitor(state)) {
promise = _addLocalTracksToConference(conference, [ jitsiTrack ]);
}
} else {
promise = _removeLocalTracksFromConference(conference, [ jitsiTrack ]);
}
if (promise) {
return promise.then(() => next(action));
}
}
}
return next(action);

View File

@@ -149,12 +149,15 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case CONNECTION_ESTABLISHED: {
if (isPrejoinPageVisible(getState())) {
let { initialGUMPromise } = getState()['features/base/media'];
const { initialGUMPromise } = getState()['features/base/media'];
const promise = initialGUMPromise ? initialGUMPromise.promise : Promise.resolve({ tracks: [] });
const prejoinVisible = isPrejoinPageVisible(getState());
initialGUMPromise = initialGUMPromise || Promise.resolve({ tracks: [] });
logger.debug(`On connection established: prejoinVisible: ${prejoinVisible}, initialGUMPromiseExists=${
Boolean(initialGUMPromise)}, promiseExists=${Boolean(promise)}`);
initialGUMPromise.then(() => {
if (prejoinVisible) {
promise.then(() => {
const state = getState();
let localTracks = getLocalTracks(state['features/base/tracks']);
const trackReplacePromises = [];
@@ -186,11 +189,7 @@ MiddlewareRegistry.register(store => next => action => {
});
});
} else {
let { initialGUMPromise } = getState()['features/base/media'];
initialGUMPromise = initialGUMPromise || Promise.resolve({ tracks: [] });
initialGUMPromise.then(({ tracks }) => {
promise.then(({ tracks }) => {
let tracksToUse = tracks ?? [];
if (iAmVisitor(getState())) {

View File

@@ -484,6 +484,8 @@ export interface IConfig {
enabled?: boolean;
hideDisplayName?: boolean;
hideExtraJoinButtons?: Array<string>;
preCallTestEnabled?: boolean;
preCallTestICEUrl?: string;
};
prejoinPageEnabled?: boolean;
raisedHands?: {
@@ -574,6 +576,7 @@ export interface IConfig {
transcribeWithAppLanguage?: boolean;
transcribingEnabled?: boolean;
transcription?: {
autoCaptionOnTranscribe?: boolean;
autoTranscribeOnRecord?: boolean;
enabled?: boolean;
preferredLanguage?: string;

View File

@@ -84,7 +84,6 @@ export default [
'deeplinking.desktop.enabled',
'defaultLocalDisplayName',
'defaultRemoteDisplayName',
'deploymentUrls',
'desktopSharingFrameRate',
'desktopSharingSources',
'disable1On1Mode',
@@ -179,7 +178,7 @@ export default [
'iceTransportPolicy',
'ignoreStartMuted',
'inviteAppName',
'liveStreaming',
'liveStreaming.enabled',
'liveStreamingEnabled',
'lobby',
'localRecording',
@@ -198,7 +197,9 @@ export default [
'pcStatsInterval',
'preferBosh',
'preferVisitor',
'prejoinConfig',
'prejoinConfig.enabled',
'prejoinConfig.hideDisplayName',
'prejoinConfig.hideExtraJoinButtons',
'prejoinPageEnabled',
'raisedHands',
'recordingService',
@@ -209,7 +210,6 @@ export default [
'recordings',
'replaceParticipant',
'resolution',
'salesforceUrl',
'screenshotCapture',
'securityUi',
'speakerStats',

View File

@@ -0,0 +1,4 @@
/**
* Deploy-specific interface_config whitelists.
*/
export default [];

View File

@@ -1,3 +1,5 @@
import extraInterfaceConfigWhitelistCopy from './extraInterfaceConfigWhitelist';
/**
* The interface config keys to whitelist, the keys that can be overridden.
*
@@ -32,7 +34,6 @@ export default [
'INDICATOR_FONT_SIZES',
'INITIAL_TOOLBAR_TIMEOUT',
'LANG_DETECTION',
'LIVE_STREAMING_HELP_LINK',
'LOCAL_THUMBNAIL_RATIO',
'MAXIMUM_ZOOMING_COEFFICIENT',
'NATIVE_APP_NAME',
@@ -45,7 +46,6 @@ export default [
'SHARING_FEATURES',
'SHOW_CHROME_EXTENSION_BANNER',
'SHOW_POWERED_BY',
'SUPPORT_URL',
'TILE_VIEW_MAX_COLUMNS',
'TOOLBAR_ALWAYS_VISIBLE',
'TOOLBAR_BUTTONS',
@@ -54,4 +54,4 @@ export default [
'VERTICAL_FILMSTRIP',
'VIDEO_LAYOUT_FIT',
'VIDEO_QUALITY_LABEL_DISABLED'
];
].concat(extraInterfaceConfigWhitelistCopy);

View File

@@ -75,6 +75,9 @@ export function configureInitialDevices() {
const deviceLabels = getDevicesFromURL(getState());
let updateSettingsPromise;
logger.debug(`(TIME) configureInitialDevices: deviceLabels=${
Boolean(deviceLabels)}, performance.now=${window.performance.now()}`);
if (deviceLabels) {
updateSettingsPromise = dispatch(getAvailableDevices()).then(() => {
const state = getState();
@@ -127,6 +130,9 @@ export function configureInitialDevices() {
.then(() => {
const userSelectedAudioOutputDeviceId = getUserSelectedOutputDeviceId(getState());
logger.debug(`(TIME) configureInitialDevices -> setAudioOutputDeviceId: performance.now=${
window.performance.now()}`);
return setAudioOutputDeviceId(userSelectedAudioOutputDeviceId, dispatch)
.catch(ex => logger.warn(`Failed to set audio output device.
Default audio output device will be used instead ${ex}`));

View File

@@ -3,6 +3,7 @@ import { AnyAction, combineReducers } from 'redux';
import { CONFERENCE_FAILED, CONFERENCE_LEFT } from '../conference/actionTypes';
import ReducerRegistry from '../redux/ReducerRegistry';
import { TRACK_REMOVED } from '../tracks/actionTypes';
import { DefferedPromise, createDeferred } from '../util/helpers';
import {
GUM_PENDING,
@@ -88,6 +89,12 @@ function _audio(state: IAudioState = _AUDIO_INITIAL_MEDIA_STATE, action: AnyActi
}
}
// Using a deferred promise here to make sure that once the connection is established even if conference.init and the
// initial track creation haven't been started we would wait for it to finish before starting to join the room.
// NOTE: The previous implementation was using the GUM promise from conference.init. But it turned out that connect
// may finish even before conference.init is executed.
const DEFAULT_INITIAL_PROMISE_STATE = createDeferred<IInitialGUMPromiseResult>();
/**
* Reducer fot the common properties in media state.
*
@@ -96,7 +103,8 @@ function _audio(state: IAudioState = _AUDIO_INITIAL_MEDIA_STATE, action: AnyActi
* @param {string} action.type - Type of action.
* @returns {ICommonState}
*/
function _initialGUMPromise(state: initialGUMPromise | null = null, action: AnyAction) {
function _initialGUMPromise(state: DefferedPromise<IInitialGUMPromiseResult> | null = DEFAULT_INITIAL_PROMISE_STATE,
action: AnyAction) {
if (action.type === SET_INITIAL_GUM_PROMISE) {
return action.promise ?? null;
}
@@ -264,10 +272,10 @@ interface IAudioState {
unmuteBlocked: boolean;
}
type initialGUMPromise = Promise<{
errors?: any;
tracks: Array<any>;
}> | null;
interface IInitialGUMPromiseResult {
errors?: any;
tracks: Array<any>;
}
interface IScreenshareState {
available: boolean;
@@ -286,7 +294,7 @@ interface IVideoState {
export interface IMediaState {
audio: IAudioState;
initialGUMPromise: initialGUMPromise;
initialGUMPromise: DefferedPromise<IInitialGUMPromiseResult> | null;
screenshare: IScreenshareState;
video: IVideoState;
}

View File

@@ -1,3 +1,9 @@
/**
* Action type to set the precall test data.
*/
export const SET_PRECALL_TEST_RESULTS = 'SET_PRECALL_TEST_RESULTS';
/**
* Type for setting the user's consent for unsafe room joining.
*
@@ -6,4 +12,4 @@
* consent: boolean
* }
*/
export const SET_UNSAFE_ROOM_CONSENT = 'SET_UNSAFE_ROOM_CONSENT'
export const SET_UNSAFE_ROOM_CONSENT = 'SET_UNSAFE_ROOM_CONSENT'

View File

@@ -1,4 +1,10 @@
import { SET_UNSAFE_ROOM_CONSENT } from './actionTypes';
import { IStore } from '../../app/types';
import JitsiMeetJS from '../lib-jitsi-meet';
import { SET_PRECALL_TEST_RESULTS, SET_UNSAFE_ROOM_CONSENT } from './actionTypes';
import { getPreCallICEUrl } from './functions';
import logger from './logger';
import { IPreCallResult, IPreCallTestState, PreCallTestStatus } from './types';
/**
* Sets the consent of the user for joining the unsafe room.
@@ -15,3 +21,47 @@ export function setUnsafeRoomConsent(consent: boolean) {
consent
};
}
/**
* Initializes the 'precallTest' and executes one test, storing the results.
*
* @returns {Function}
*/
export function runPreCallTest() {
return async function(dispatch: Function, getState: IStore['getState']) {
try {
dispatch(setPreCallTestResults({ status: PreCallTestStatus.RUNNING }));
const turnCredentialsUrl = getPreCallICEUrl(getState());
if (!turnCredentialsUrl) {
throw new Error('No TURN credentials URL provided in config');
}
const turnCredentials = await fetch(turnCredentialsUrl);
const { iceServers } = await turnCredentials.json();
const result: IPreCallResult = await JitsiMeetJS.runPreCallTest(iceServers);
dispatch(setPreCallTestResults({ status: PreCallTestStatus.FINISHED,
result }));
} catch (error) {
logger.error('Failed to run pre-call test', error);
dispatch(setPreCallTestResults({ status: PreCallTestStatus.FAILED }));
}
};
}
/**
* Action used to set data from precall test.
*
* @param {IPreCallTestState} value - The precall test results.
* @returns {Object}
*/
export function setPreCallTestResults(value: IPreCallTestState) {
return {
type: SET_PRECALL_TEST_RESULTS,
value
};
}

View File

@@ -1,29 +1,17 @@
import React, { useCallback, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { translate } from '../../../i18n/functions';
import Icon from '../../../icons/components/Icon';
import { IconArrowDown, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons/svg';
import { IconArrowDown, IconCloseCircle, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { PREJOIN_DEFAULT_CONTENT_WIDTH } from '../../../ui/components/variables';
import Spinner from '../../../ui/components/web/Spinner';
import { runPreCallTest } from '../../actions.web';
import { CONNECTION_TYPE } from '../../constants';
import { getConnectionData } from '../../functions';
interface IProps extends WithTranslation {
/**
* List of strings with details about the connection.
*/
connectionDetails?: string[];
/**
* The type of the connection. Can be: 'none', 'poor', 'nonOptimal' or 'good'.
*/
connectionType?: string;
}
const useStyles = makeStyles()(theme => {
return {
connectionStatus: {
@@ -68,6 +56,10 @@ const useStyles = makeStyles()(theme => {
background: '#31B76A'
},
'& .con-status--failed': {
background: '#E12D2D'
},
'& .con-status--poor': {
background: '#E12D2D'
},
@@ -122,6 +114,11 @@ const CONNECTION_TYPE_MAP: {
icon: Function;
};
} = {
[CONNECTION_TYPE.FAILED]: {
connectionClass: 'con-status--failed',
icon: IconCloseCircle,
connectionText: 'prejoin.connection.failed'
},
[CONNECTION_TYPE.POOR]: {
connectionClass: 'con-status--poor',
icon: IconWifi1Bar,
@@ -145,10 +142,17 @@ const CONNECTION_TYPE_MAP: {
* @param {IProps} props - The props of the component.
* @returns {ReactElement}
*/
function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) {
const ConnectionStatus = () => {
const { classes } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const { connectionType, connectionDetails } = useSelector(getConnectionData);
const [ showDetails, toggleDetails ] = useState(false);
useEffect(() => {
dispatch(runPreCallTest());
}, []);
const arrowClassName = showDetails
? 'con-status-arrow con-status-arrow--up'
: 'con-status-arrow';
@@ -173,6 +177,26 @@ function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) {
return null;
}
if (connectionType === CONNECTION_TYPE.RUNNING) {
return (
<div className = { classes.connectionStatus }>
<div
aria-level = { 1 }
className = 'con-status-header'
role = 'heading'>
<div className = 'con-status-circle'>
<Spinner
color = { 'green' }
size = 'medium' />
</div>
<span
className = 'con-status-text'
id = 'connection-status-description'>{t('prejoin.connection.running')}</span>
</div>
</div>
);
}
const { connectionClass, icon, connectionText } = CONNECTION_TYPE_MAP[connectionType ?? ''];
return (
@@ -208,21 +232,6 @@ function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) {
{detailsText}</div>
</div>
);
}
};
/**
* Maps (parts of) the redux state to the React {@code Component} props.
*
* @param {Object} state - The redux state.
* @returns {Object}
*/
function mapStateToProps() {
const { connectionDetails, connectionType } = getConnectionData();
return {
connectionDetails,
connectionType
};
}
export default translate(connect(mapStateToProps)(ConnectionStatus));
export default ConnectionStatus;

View File

@@ -11,6 +11,7 @@ import { isButtonEnabled } from '../../../../toolbox/functions.web';
import { getConferenceName } from '../../../conference/functions';
import { PREMEETING_BUTTONS, THIRD_PARTY_PREJOIN_BUTTONS } from '../../../config/constants';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { isPreCallTestEnabled } from '../../functions';
import ConnectionStatus from './ConnectionStatus';
import Preview from './Preview';
@@ -24,6 +25,11 @@ interface IProps {
*/
_buttons: Array<string>;
/**
* Determine if pre call test is enabled.
*/
_isPreCallTestEnabled?: boolean;
/**
* The branding background of the premeeting screen(lobby/prejoin).
*/
@@ -169,6 +175,7 @@ const useStyles = makeStyles()(theme => {
const PreMeetingScreen = ({
_buttons,
_isPreCallTestEnabled,
_premeetingBackground,
_roomName,
children,
@@ -188,11 +195,13 @@ const PreMeetingScreen = ({
backgroundSize: 'cover'
} : {};
console.log('Rendering premeeting....');
return (
<div className = { clsx('premeeting-screen', classes.container, className) }>
<div style = { style }>
<div className = { classes.content }>
<ConnectionStatus />
{_isPreCallTestEnabled && <ConnectionStatus />}
<div className = { classes.contentControls }>
<h1 className = { classes.title }>
@@ -245,6 +254,7 @@ function mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
_buttons: hiddenPremeetingButtons
? premeetingButtons
: premeetingButtons.filter(b => isButtonEnabled(b, toolbarButtons)),
_isPreCallTestEnabled: isPreCallTestEnabled(state),
_premeetingBackground: premeetingBackground,
_roomName: isRoomNameEnabled(state) ? getConferenceName(state) : ''
};

View File

@@ -1,6 +1,8 @@
export const CONNECTION_TYPE = {
FAILED: 'failed',
GOOD: 'good',
NON_OPTIMAL: 'nonOptimal',
NONE: 'none',
POOR: 'poor'
POOR: 'poor',
RUNNING: 'running'
};

View File

@@ -1,4 +1,10 @@
import { findIndex } from 'lodash-es';
import { IReduxState } from '../../app/types';
import { CONNECTION_TYPE } from './constants';
import logger from './logger';
import { IPreCallResult, PreCallTestStatus } from './types';
/**
@@ -31,6 +37,14 @@ const defaultMarginTop = '10%';
*/
const smallMarginTop = '5%';
// loss in percentage overall the test duration
const LOSS_AUDIO_THRESHOLDS = [ 0.33, 0.05 ];
const LOSS_VIDEO_THRESHOLDS = [ 0.33, 0.1, 0.05 ];
// throughput in kbps
const THROUGHPUT_AUDIO_THRESHOLDS = [ 8, 20 ];
const THROUGHPUT_VIDEO_THRESHOLDS = [ 60, 750 ];
/**
* Calculates avatar dimensions based on window height and position.
*
@@ -71,16 +85,187 @@ export function calculateAvatarDimensions(height: number) {
}
/**
* Selector for determining the connection type & details.
* Returns the level based on a list of thresholds.
*
* @param {number[]} thresholds - The thresholds array.
* @param {number} value - The value against which the level is calculated.
* @param {boolean} descending - The order based on which the level is calculated.
*
* @returns {number}
*/
function _getLevel(thresholds: number[], value: number, descending = true) {
let predicate;
if (descending) {
predicate = function(threshold: number) {
return value > threshold;
};
} else {
predicate = function(threshold: number) {
return value < threshold;
};
}
const i = findIndex(thresholds, predicate);
if (i === -1) {
return thresholds.length;
}
return i;
}
/**
* Returns the connection details from the test results.
*
* @param {number} testResults.fractionalLoss - Factional loss.
* @param {number} testResults.throughput - Throughput.
*
* @returns {{
* connectionType: string,
* connectionDetails: string[]
* }}
*/
export function getConnectionData() {
* connectionType: string,
* connectionDetails: string[]
* }}
*/
function _getConnectionDataFromTestResults({ fractionalLoss: l, throughput: t, mediaConnectivity }: IPreCallResult) {
let connectionType = CONNECTION_TYPE.FAILED;
const connectionDetails: Array<string> = [];
if (!mediaConnectivity) {
connectionType = CONNECTION_TYPE.POOR;
connectionDetails.push('prejoin.connectionDetails.noMediaConnectivity');
return {
connectionType,
connectionDetails
};
}
const loss = {
audioQuality: _getLevel(LOSS_AUDIO_THRESHOLDS, l),
videoQuality: _getLevel(LOSS_VIDEO_THRESHOLDS, l)
};
const throughput = {
audioQuality: _getLevel(THROUGHPUT_AUDIO_THRESHOLDS, t, false),
videoQuality: _getLevel(THROUGHPUT_VIDEO_THRESHOLDS, t, false)
};
if (throughput.audioQuality === 0 || loss.audioQuality === 0) {
// Calls are impossible.
connectionType = CONNECTION_TYPE.POOR;
connectionDetails.push('prejoin.connectionDetails.veryPoorConnection');
} else if (
throughput.audioQuality === 2
&& throughput.videoQuality === 2
&& loss.audioQuality === 2
&& loss.videoQuality === 3
) {
// Ideal conditions for both audio and video. Show only one message.
connectionType = CONNECTION_TYPE.GOOD;
connectionDetails.push('prejoin.connectionDetails.goodQuality');
} else {
connectionType = CONNECTION_TYPE.NON_OPTIMAL;
if (throughput.audioQuality === 1) {
// Minimum requirements for a call are met.
connectionDetails.push('prejoin.connectionDetails.audioLowNoVideo');
} else {
// There are two paragraphs: one saying something about audio and the other about video.
if (loss.audioQuality === 1) {
connectionDetails.push('prejoin.connectionDetails.audioClipping');
} else {
connectionDetails.push('prejoin.connectionDetails.audioHighQuality');
}
if (throughput.videoQuality === 0 || loss.videoQuality === 0) {
connectionDetails.push('prejoin.connectionDetails.noVideo');
} else if (throughput.videoQuality === 1) {
connectionDetails.push('prejoin.connectionDetails.videoLowQuality');
} else if (loss.videoQuality === 1) {
connectionDetails.push('prejoin.connectionDetails.videoFreezing');
} else if (loss.videoQuality === 2) {
connectionDetails.push('prejoin.connectionDetails.videoTearing');
} else {
connectionDetails.push('prejoin.connectionDetails.videoHighQuality');
}
}
connectionDetails.push('prejoin.connectionDetails.undetectable');
}
return {
connectionType: CONNECTION_TYPE.NONE,
connectionDetails: []
connectionType,
connectionDetails
};
}
/**
* Selector for determining the connection type & details.
*
* @param {Object} state - The state of the app.
* @returns {{
* connectionType: string,
* connectionDetails: string[]
* }}
*/
export function getConnectionData(state: IReduxState) {
const { preCallTestState: { status, result } } = state['features/base/premeeting'];
switch (status) {
case PreCallTestStatus.INITIAL:
return {
connectionType: CONNECTION_TYPE.NONE,
connectionDetails: []
};
case PreCallTestStatus.RUNNING:
return {
connectionType: CONNECTION_TYPE.RUNNING,
connectionDetails: []
};
case PreCallTestStatus.FAILED:
// A failed test means that something went wrong with our business logic and not necessarily
// that the connection is bad. For instance, the endpoint providing the ICE credentials could be down.
return {
connectionType: CONNECTION_TYPE.FAILED,
connectionDetails: [ 'prejoin.connectionDetails.testFailed' ]
};
case PreCallTestStatus.FINISHED:
if (result) {
return _getConnectionDataFromTestResults(result);
}
logger.error('Pre-call test finished but no test results were available');
return {
connectionType: CONNECTION_TYPE.FAILED,
connectionDetails: [ 'prejoin.connectionDetails.testFailed' ]
};
default:
return {
connectionType: CONNECTION_TYPE.NONE,
connectionDetails: []
};
}
}
/**
* Selector for determining if the pre-call test is enabled.
*
* @param {Object} state - The state of the app.
* @returns {boolean}
*/
export function isPreCallTestEnabled(state: IReduxState): boolean {
const { prejoinConfig } = state['features/base/config'];
return prejoinConfig?.preCallTestEnabled ?? false;
}
/**
* Selector for retrieving the pre-call test ICE URL.
*
* @param {Object} state - The state of the app.
* @returns {string | undefined}
*/
export function getPreCallICEUrl(state: IReduxState): string | undefined {
const { prejoinConfig } = state['features/base/config'];
return prejoinConfig?.preCallTestICEUrl;
}

View File

@@ -1,10 +1,13 @@
import ReducerRegistry from '../redux/ReducerRegistry';
import { SET_UNSAFE_ROOM_CONSENT } from './actionTypes';
import { IPreMeetingState } from './types';
import { SET_PRECALL_TEST_RESULTS, SET_UNSAFE_ROOM_CONSENT } from './actionTypes';
import { IPreMeetingState, PreCallTestStatus } from './types';
const DEFAULT_STATE: IPreMeetingState = {
preCallTestState: {
status: PreCallTestStatus.INITIAL
},
unsafeRoomConsent: false
};
@@ -20,6 +23,12 @@ ReducerRegistry.register<IPreMeetingState>(
'features/base/premeeting',
(state = DEFAULT_STATE, action): IPreMeetingState => {
switch (action.type) {
case SET_PRECALL_TEST_RESULTS:
return {
...state,
preCallTestState: action.value
};
case SET_UNSAFE_ROOM_CONSENT: {
return {
...state,

View File

@@ -1,3 +1,25 @@
export enum PreCallTestStatus {
FAILED = 'FAILED',
FINISHED = 'FINISHED',
INITIAL = 'INITIAL',
RUNNING = 'RUNNING'
}
export interface IPreMeetingState {
preCallTestState: IPreCallTestState;
unsafeRoomConsent?: boolean;
}
export interface IPreCallTestState {
result?: IPreCallResult;
status: PreCallTestStatus;
}
export interface IPreCallResult {
fractionalLoss: number;
jitter: number;
mediaConnectivity: boolean;
rtt: number;
throughput: number;
}

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { withPixelLineHeight } from '../../../styles/functions.web';
import Button from '../../../ui/components/web/Button';
import { getSupportUrl } from '../../functions';
const useStyles = makeStyles()(theme => {
return {
@@ -50,7 +52,7 @@ const InlineDialogFailure = ({
const { t } = useTranslation();
const { classes } = useStyles();
const supportLink = interfaceConfig.SUPPORT_URL;
const supportLink = useSelector(getSupportUrl);
const supportString = t('inlineDialogFailure.supportMsg');
const supportLinkElem = supportLink && showSupportLink
? (

View File

@@ -1,5 +1,8 @@
import punycode from 'punycode';
import { IStateful } from '../app/types';
import { toState } from '../redux/functions';
/**
* Returns the field value in a platform generic way.
*
@@ -47,3 +50,16 @@ export function formatURLText(text = '') {
return result;
}
/**
* Returns the configured support URL.
*
* @param {IStateful} stateful - The redux state.
* @returns {string|undefined} - The configured support link.
*/
export function getSupportUrl(stateful: IStateful) {
// TODO: Once overwriting trough interface config is completelly gone we should think of a way to be able to set
// the value in the branding and not return the default value from interface config.
return toState(stateful)['features/dynamic-branding'].supportUrl || interfaceConfig?.SUPPORT_URL;
}

View File

@@ -352,9 +352,10 @@ export function createAndAddInitialAVTracks(devices: Array<MediaType>) {
* Creates the initial audio/video tracks.
*
* @param {ICreateInitialTracksOptions} options - Options for creating the audio/video tracks.
* @param {boolean} recordTimeMetrics - If true time metrics will be recorded.
* @returns {Function}
*/
export function createInitialAVTracks(options: ICreateInitialTracksOptions) {
export function createInitialAVTracks(options: ICreateInitialTracksOptions, recordTimeMetrics = false) {
return (dispatch: IStore['dispatch'], _getState: IStore['getState']) => {
const {
devices,
@@ -364,7 +365,7 @@ export function createInitialAVTracks(options: ICreateInitialTracksOptions) {
dispatch(gumPending(devices, IGUMPendingState.PENDING_UNMUTE));
return createLocalTracksF(options).then(tracks => {
return createLocalTracksF(options, undefined, recordTimeMetrics).then(tracks => {
return {
errors: {} as IInitialTracksErrors,
tracks

View File

@@ -11,6 +11,7 @@ import {
getUserSelectedCameraDeviceId,
getUserSelectedMicDeviceId
} from '../settings/functions.web';
import { getJitsiMeetGlobalNSConnectionTimes } from '../util/helpers';
import { getCameraFacingMode } from './functions.any';
import loadEffects from './loadEffects';
@@ -36,9 +37,10 @@ export * from './functions.any';
* corresponding event.
* @param {IStore} store - The redux store in the context of which the function
* is to execute and from which state such as {@code config} is to be retrieved.
* @param {boolean} recordTimeMetrics - If true time metrics will be recorded.
* @returns {Promise<JitsiLocalTrack[]>}
*/
export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore) {
export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore, recordTimeMetrics = false) {
let { cameraDeviceId, micDeviceId } = options;
const {
desktopSharingSourceDevice,
@@ -69,6 +71,10 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore)
return (
loadEffects(store).then((effectsArray: Object[]) => {
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['trackEffects.loaded'] = window.performance.now();
}
// Filter any undefined values returned by Promise.resolve().
const effects = effectsArray.filter(effect => Boolean(effect));

View File

@@ -11,6 +11,7 @@ import logger from './logger';
* @returns {Promise} - A Promise which resolves when all effects are created.
*/
export default function loadEffects(store: IStore): Promise<any> {
const start = window.performance.now();
const state = store.getState();
const virtualBackground = state['features/virtual-background'];
const noiseSuppression = state['features/noise-suppression'];
@@ -30,5 +31,11 @@ export default function loadEffects(store: IStore): Promise<any> {
? Promise.resolve(new NoiseSuppressionEffect(nsOptions))
: Promise.resolve();
return Promise.all([ backgroundPromise, noiseSuppressionPromise ]);
return Promise.all([ backgroundPromise, noiseSuppressionPromise ]).then(effectsArray => {
const end = window.performance.now();
logger.debug(`(TIME) loadEffects() start=${start}, end=${end}, time=${end - start}`);
return effectsArray;
});
}

View File

@@ -22,16 +22,21 @@ export function assignIfDefined(target: Object, source: Object) {
return to;
}
export type DefferedPromise<T> = {
promise: Promise<T>;
reject: (reason?: any) => void;
resolve: (value: T) => void;
};
/**
* Creates a deferred object.
*
* @returns {{promise, resolve, reject}}
*/
export function createDeferred() {
const deferred: any = {};
export function createDeferred<T>() {
const deferred = {} as DefferedPromise<T>;
deferred.promise = new Promise((resolve, reject) => {
deferred.promise = new Promise<T>((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
@@ -95,6 +100,21 @@ export function getJitsiMeetGlobalNS() {
return window.JitsiMeetJS.app;
}
/**
* Returns the object that stores the connection times.
*
* @returns {Object} - The object that stores the connection times.
*/
export function getJitsiMeetGlobalNSConnectionTimes() {
const globalNS = getJitsiMeetGlobalNS();
if (!globalNS.connectionTimes) {
globalNS.connectionTimes = {};
}
return globalNS.connectionTimes;
}
/**
* Prints the error and reports it to the global error handler.
*

View File

@@ -127,11 +127,14 @@ MiddlewareRegistry.register(store => next => action => {
const { participant, data } = action;
if (data?.name === ENDPOINT_REACTION_NAME) {
store.dispatch(pushReactions(data.reactions));
// Skip duplicates, keep just 3.
const reactions = Array.from(new Set(data.reactions)).slice(0, 3) as string[];
store.dispatch(pushReactions(reactions));
_handleReceivedMessage(store, {
participantId: participant.getId(),
message: getReactionMessageFromBuffer(data.reactions),
message: getReactionMessageFromBuffer(reactions),
privateMessage: false,
lobbyChat: false,
timestamp: data.timestamp

View File

@@ -1,6 +1,7 @@
import { IStore } from '../app/types';
import { configureInitialDevices, getAvailableDevices } from '../base/devices/actions.web';
import { openDialog } from '../base/dialog/actions';
import { getJitsiMeetGlobalNSConnectionTimes } from '../base/util/helpers';
import { getBackendSafeRoomName } from '../base/util/uri';
import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
@@ -37,29 +38,51 @@ export function dismissCalendarNotification() {
/**
* Setups initial devices. Makes sure we populate availableDevices list before configuring.
*
* @param {boolean} recordTimeMetrics - If true, an analytics time metrics will be sent.
* @returns {Promise<any>}
*/
export function setupInitialDevices() {
export function setupInitialDevices(recordTimeMetrics = false) {
return async (dispatch: IStore['dispatch']) => {
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.start'] = window.performance.now();
}
await dispatch(getAvailableDevices());
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.getAD.finished'] = window.performance.now();
}
await dispatch(configureInitialDevices());
const now = window.performance.now();
if (recordTimeMetrics) {
getJitsiMeetGlobalNSConnectionTimes()['setupInitialDevices.end'] = now;
}
logger.debug(`(TIME) setupInitialDevices finished: ${now}`);
};
}
/**
* Init.
*
* @param {boolean} shouldDispatchConnect - Whether or not connect should be dispatched. This should be false only when
* prejoin is enabled.
* @returns {Promise<JitsiConnection>}
*/
export function init() {
export function init(shouldDispatchConnect: boolean) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
logger.debug(`(TIME) init action dispatched: ${window.performance.now()}`);
const room = getBackendSafeRoomName(getState()['features/base/conference'].room);
// XXX For web based version we use conference initialization logic
// from the old app (at the moment of writing).
return dispatch(setupInitialDevices()).then(
return dispatch(setupInitialDevices(true)).then(
() => APP.conference.init({
roomName: room
roomName: room,
shouldDispatchConnect
}).catch((error: Error) => {
APP.API.notifyConferenceLeft(APP.conference.roomName);
logger.error(error);

View File

@@ -104,12 +104,24 @@ interface IProps extends AbstractProps, WithTranslation {
/**
* If visitors queue page is visible or not.
* NOTE: This should be set to true once we received an error on connect. Before the first connect this will always
* be false.
*/
_showVisitorsQueue: boolean;
dispatch: IStore['dispatch'];
}
/**
* Returns true if the prejoin screen should be displayed and false otherwise.
*
* @param {IProps} props - The props object.
* @returns {boolean} - True if the prejoin screen should be displayed and false otherwise.
*/
function shouldShowPrejoin({ _showPrejoin, _showVisitorsQueue }: IProps) {
return _showPrejoin && !_showVisitorsQueue;
}
/**
* The conference page of the Web application.
*/
@@ -265,7 +277,7 @@ class Conference extends AbstractConference<IProps, any> {
<CalleeInfoContainer />
{ (_showPrejoin && !_showVisitorsQueue) && <Prejoin />}
{ shouldShowPrejoin(this.props) && <Prejoin />}
{ (_showLobby && !_showVisitorsQueue) && <LobbyScreen />}
{ _showVisitorsQueue && <VisitorsQueue />}
</div>
@@ -384,7 +396,9 @@ class Conference extends AbstractConference<IProps, any> {
const { dispatch, t } = this.props;
dispatch(init());
// if we will be showing prejoin we don't want to call connect from init.
// Connect will be dispatched from prejoin screen.
dispatch(init(!shouldShowPrejoin(this.props)));
maybeShowSuboptimalExperienceNotification(dispatch, t);
}

View File

@@ -25,7 +25,8 @@ MiddlewareRegistry.register(store => next => action => {
didPageUrl,
inviteDomain,
labels,
sharedVideoAllowedURLDomains
sharedVideoAllowedURLDomains,
supportUrl
} = action.value;
action.value = {
@@ -36,7 +37,8 @@ MiddlewareRegistry.register(store => next => action => {
didPageUrl,
inviteDomain,
labels,
sharedVideoAllowedURLDomains
sharedVideoAllowedURLDomains,
supportUrl
};
// The backend may send an empty string, make sure we skip that.

View File

@@ -159,6 +159,7 @@ export interface IDynamicBrandingState {
premeetingBackground: string;
sharedVideoAllowedURLDomains?: Array<string>;
showGiphyIntegration?: boolean;
supportUrl?: string;
useDynamicBrandingData: boolean;
virtualBackgrounds: Array<Image>;
}
@@ -184,6 +185,7 @@ ReducerRegistry.register<IDynamicBrandingState>(STORE_NAME, (state = DEFAULT_STA
premeetingBackground,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
supportUrl,
virtualBackgrounds
} = action.value;
@@ -202,6 +204,7 @@ ReducerRegistry.register<IDynamicBrandingState>(STORE_NAME, (state = DEFAULT_STA
premeetingBackground,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
supportUrl,
customizationFailed: false,
customizationReady: true,
useDynamicBrandingData: true,

View File

@@ -61,3 +61,13 @@ StateListenerRegistry.register(
APP.API.notifyOnStageParticipantChanged(participantId);
}
);
/**
* Updates the on audio only value.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/audio-only'].enabled,
/* listener */ enabled => {
APP.API.notifyAudioOnlyChanged(enabled);
}
);

View File

@@ -1,6 +1,7 @@
import { Theme } from '@mui/material';
import React, { isValidElement, useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
@@ -15,6 +16,7 @@ import {
IconWarningCircle
} from '../../../base/icons/svg';
import Message from '../../../base/react/components/web/Message';
import { getSupportUrl } from '../../../base/react/functions';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { NOTIFICATION_ICON, NOTIFICATION_TYPE } from '../../constants';
import { INotificationProps } from '../../types';
@@ -190,6 +192,7 @@ const Notification = ({
const { classes, cx, theme } = useStyles();
const { t } = useTranslation();
const { unmounting } = useContext(NotificationsTransitionContext);
const supportUrl = useSelector(getSupportUrl);
const ICON_COLOR = {
error: theme.palette.iconError,
@@ -229,9 +232,9 @@ const Notification = ({
);
}, [ description, descriptionArguments, descriptionKey, classes ]);
const _onOpenSupportLink = () => {
window.open(interfaceConfig.SUPPORT_URL, '_blank', 'noopener');
};
const _onOpenSupportLink = useCallback(() => {
window.open(supportUrl, '_blank', 'noopener');
}, [ supportUrl ]);
const mapAppearanceToButtons = useCallback((): {
content: string; onClick: () => void; testId?: string; type?: string; }[] => {
@@ -244,7 +247,7 @@ const Notification = ({
}
];
if (!hideErrorSupportLink && interfaceConfig.SUPPORT_URL) {
if (!hideErrorSupportLink && supportUrl) {
buttons.push({
content: t('dialog.contactSupport'),
onClick: _onOpenSupportLink
@@ -279,7 +282,7 @@ const Notification = ({
return [];
}
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink ]);
}, [ appearance, onDismiss, customActionHandler, customActionNameKey, hideErrorSupportLink, supportUrl ]);
const getIcon = useCallback(() => {
let iconToDisplay;
@@ -313,7 +316,7 @@ const Notification = ({
<div
aria-atomic = 'false'
aria-live = 'polite'
className = { cx(classes.container, unmounting.get(uid ?? '') && 'unmount') }
className = { cx(classes.container, (unmounting.get(uid ?? '') && 'unmount') as string | undefined) }
data-testid = { titleKey || descriptionKey }
id = { uid }>
<div className = { cx(classes.ribbon, appearance) } />

View File

@@ -9,6 +9,17 @@
*/
export const REMOVE_TRANSCRIPT_MESSAGE = 'REMOVE_TRANSCRIPT_MESSAGE';
/**
* The type of (redux) action which indicates that an cached transcript
* has to be removed from the state.
*
* {
* type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
* transciptMessageID: string,
* }
*/
export const REMOVE_CACHED_TRANSCRIPT_MESSAGE = 'REMOVE_CACHED_TRANSCRIPT_MESSAGE';
/**
* The type of (redux) action which indicates that a transcript with an
* given message_id to be added or updated is received.

View File

@@ -1,6 +1,7 @@
import { DEFAULT_LANGUAGE } from '../base/i18n/i18next';
import {
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
REMOVE_TRANSCRIPT_MESSAGE,
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES,
@@ -23,6 +24,22 @@ export function removeTranscriptMessage(transcriptMessageID: string) {
};
}
/**
* Signals that a cached transcript has to be removed from the state.
*
* @param {string} transcriptMessageID - The message_id to be removed.
* @returns {{
* type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
* transcriptMessageID: string,
* }}
*/
export function removeCachedTranscriptMessage(transcriptMessageID: string) {
return {
type: REMOVE_CACHED_TRANSCRIPT_MESSAGE,
transcriptMessageID
};
}
/**
* Signals that a transcript with the given message_id to be added or updated
* is received.

View File

@@ -6,12 +6,14 @@ import { isJwtFeatureEnabled } from '../base/jwt/functions';
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { isLocalParticipantModerator } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { TRANSCRIBER_JOINED } from '../transcribing/actionTypes';
import {
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES
} from './actionTypes';
import {
removeCachedTranscriptMessage,
removeTranscriptMessage,
setRequestingSubtitles,
updateTranscriptMessage
@@ -80,6 +82,15 @@ MiddlewareRegistry.register(store => next => action => {
_requestingSubtitlesChange(store, toggledValue, state._language);
break;
}
case TRANSCRIBER_JOINED: {
const { transcription } = store.getState()['features/base/config'];
if (transcription?.autoCaptionOnTranscribe) {
store.dispatch(setRequestingSubtitles(true));
}
break;
}
case SET_REQUESTING_SUBTITLES:
_requestingSubtitlesChange(store, action.enabled, action.language);
break;
@@ -124,18 +135,16 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
name
};
let newTranscriptMessage: ITranscriptMessage | undefined;
if (json.type === JSON_TYPE_TRANSLATION_RESULT && json.language === language) {
// Displays final results in the target language if translation is
// enabled.
const newTranscriptMessage = {
newTranscriptMessage = {
clearTimeOut: undefined,
final: json.text,
final: json.text?.trim(),
participant
};
_setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage));
} else if (json.type === JSON_TYPE_TRANSCRIPTION_RESULT) {
// Displays interim and final results without any translation if
// translations are disabled.
@@ -199,13 +208,12 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
// message ID or adds a new transcript message if it does not
// exist in the map.
const existingMessage = state['features/subtitles']._transcriptMessages.get(transcriptMessageID);
const newTranscriptMessage: ITranscriptMessage = {
newTranscriptMessage = {
clearTimeOut: existingMessage?.clearTimeOut,
participant
};
_setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
// If this is final result, update the state as a final result
// and start a count down to remove the subtitle from the state
if (!json.is_interim) {
@@ -221,7 +229,31 @@ function _endpointMessageReceived(store: IStore, next: Function, action: AnyActi
// after the stable part.
newTranscriptMessage.unstable = text;
}
}
if (newTranscriptMessage) {
if (newTranscriptMessage.final) {
const cachedTranscriptMessage
= state['features/subtitles']._cachedTranscriptMessages?.get(transcriptMessageID);
if (cachedTranscriptMessage) {
const cachedText = (cachedTranscriptMessage.stable || cachedTranscriptMessage.unstable)?.trim();
const newText = newTranscriptMessage.final;
if (cachedText && cachedText.length > 0 && newText && newText.length > 0
&& newText.toLowerCase().startsWith(cachedText.toLowerCase())) {
newTranscriptMessage.final = newText.slice(cachedText.length)?.trim();
}
dispatch(removeCachedTranscriptMessage(transcriptMessageID));
if (!newTranscriptMessage.final || newTranscriptMessage.final.length === 0) {
return next(action);
}
}
}
_setClearerOnTranscriptMessage(dispatch, transcriptMessageID, newTranscriptMessage);
dispatch(updateTranscriptMessage(transcriptMessageID, newTranscriptMessage));
}

View File

@@ -2,6 +2,7 @@ import ReducerRegistry from '../base/redux/ReducerRegistry';
import { TRANSCRIBER_LEFT } from '../transcribing/actionTypes';
import {
REMOVE_CACHED_TRANSCRIPT_MESSAGE,
REMOVE_TRANSCRIPT_MESSAGE,
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES,
@@ -13,6 +14,7 @@ import { ITranscriptMessage } from './types';
* Default State for 'features/transcription' feature.
*/
const defaultState = {
_cachedTranscriptMessages: new Map(),
_displaySubtitles: false,
_transcriptMessages: new Map(),
_requestingSubtitles: false,
@@ -20,6 +22,7 @@ const defaultState = {
};
export interface ISubtitlesState {
_cachedTranscriptMessages: Map<string, ITranscriptMessage>;
_displaySubtitles: boolean;
_language: string | null;
_requestingSubtitles: boolean;
@@ -35,6 +38,8 @@ ReducerRegistry.register<ISubtitlesState>('features/subtitles', (
switch (action.type) {
case REMOVE_TRANSCRIPT_MESSAGE:
return _removeTranscriptMessage(state, action);
case REMOVE_CACHED_TRANSCRIPT_MESSAGE:
return _removeCachedTranscriptMessage(state, action);
case UPDATE_TRANSCRIPT_MESSAGE:
return _updateTranscriptMessage(state, action);
case SET_REQUESTING_SUBTITLES:
@@ -70,16 +75,45 @@ ReducerRegistry.register<ISubtitlesState>('features/subtitles', (
*/
function _removeTranscriptMessage(state: ISubtitlesState, { transcriptMessageID }: { transcriptMessageID: string; }) {
const newTranscriptMessages = new Map(state._transcriptMessages);
const message = newTranscriptMessages.get(transcriptMessageID);
let { _cachedTranscriptMessages } = state;
if (message && !message.final) {
_cachedTranscriptMessages = new Map(_cachedTranscriptMessages);
_cachedTranscriptMessages.set(transcriptMessageID, message);
}
// Deletes the key from Map once a final message arrives.
newTranscriptMessages.delete(transcriptMessageID);
return {
...state,
_cachedTranscriptMessages,
_transcriptMessages: newTranscriptMessages
};
}
/**
* Reduces a specific Redux action REMOVE_CACHED_TRANSCRIPT_MESSAGE of the feature transcription.
*
* @param {Object} state - The Redux state of the feature transcription.
* @param {Action} action -The Redux action REMOVE_CACHED_TRANSCRIPT_MESSAGE to reduce.
* @returns {Object} The new state of the feature transcription after the reduction of the specified action.
*/
function _removeCachedTranscriptMessage(state: ISubtitlesState,
{ transcriptMessageID }: { transcriptMessageID: string; }) {
const newCachedTranscriptMessages = new Map(state._cachedTranscriptMessages);
// Deletes the key from Map once a final message arrives.
newCachedTranscriptMessages.delete(transcriptMessageID);
return {
...state,
_cachedTranscriptMessages: newCachedTranscriptMessages
};
}
/**
* Reduces a specific Redux action UPDATE_TRANSCRIPT_MESSAGE of the feature
* transcription.
@@ -92,12 +126,16 @@ function _removeTranscriptMessage(state: ISubtitlesState, { transcriptMessageID
function _updateTranscriptMessage(state: ISubtitlesState, { transcriptMessageID, newTranscriptMessage }:
{ newTranscriptMessage: ITranscriptMessage; transcriptMessageID: string; }) {
const newTranscriptMessages = new Map(state._transcriptMessages);
const _cachedTranscriptMessages = new Map(state._cachedTranscriptMessages);
_cachedTranscriptMessages.delete(transcriptMessageID);
// Updates the new message for the given key in the Map.
newTranscriptMessages.set(transcriptMessageID, newTranscriptMessage);
return {
...state,
_cachedTranscriptMessages,
_transcriptMessages: newTranscriptMessages
};
}

View File

@@ -3,6 +3,7 @@ import { showErrorNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { TRANSCRIBER_LEFT } from './actionTypes';
import './subscriber';
/**
* Implements the middleware of the feature transcribing.

View File

@@ -12,7 +12,7 @@ import { isLiveStreamingRunning, isRecordingRunning } from '../recording/functio
import { isRecorderTranscriptionsRunning } from './functions';
/**
* Listens for large video participant ID changes.
* Listens for transcriber status change.
*/
StateListenerRegistry.register(
/* selector */ isRecorderTranscriptionsRunning,

View File

@@ -194,7 +194,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
delay = msg.randomDelayMs;
}
if (WebsocketClient.getInstance().connectCount > 1) {
if (WebsocketClient.getInstance().connectCount > 3) {
// if we keep connecting/disconnecting, let's slow it down
delay = 30 * 1000;
}

View File

@@ -4,7 +4,7 @@ import ReactDOM from 'react-dom';
import { App } from './features/app/components/App.web';
import { getLogger } from './features/base/logging/functions';
import Platform from './features/base/react/Platform.web';
import { getJitsiMeetGlobalNS } from './features/base/util/helpers';
import { getJitsiMeetGlobalNS, getJitsiMeetGlobalNSConnectionTimes } from './features/base/util/helpers';
import DialInSummaryApp from './features/invite/components/dial-in-summary/web/DialInSummaryApp';
import PrejoinApp from './features/prejoin/components/web/PrejoinApp';
import WhiteboardApp from './features/whiteboard/components/web/WhiteboardApp';
@@ -45,20 +45,19 @@ if (Platform.OS === 'ios') {
}
const globalNS = getJitsiMeetGlobalNS();
const connectionTimes = getJitsiMeetGlobalNSConnectionTimes();
// Used for automated performance tests.
globalNS.connectionTimes = {
'index.loaded': window.indexLoadedTime
};
connectionTimes['index.loaded'] = window.indexLoadedTime;
window.addEventListener('load', () => {
globalNS.connectionTimes['window.loaded'] = window.loadedEventTime;
connectionTimes['window.loaded'] = window.loadedEventTime;
});
document.addEventListener('DOMContentLoaded', () => {
const now = window.performance.now();
globalNS.connectionTimes['document.ready'] = now;
connectionTimes['document.ready'] = now;
logger.log('(TIME) document ready:\t', now);
});

View File

@@ -353,15 +353,15 @@ module:hook('muc-broadcast-presence', function (event)
module:send(promotion_request);
end
local requestTranscriptionValue = full_p:get_child_text(PARTICIPANT_PROP_REQUEST_TRANSCRIPTION);
local hasTranscriptionEnabled = room._transcription_languages and room._transcription_languages[occupant.jid];
-- detect transcription
if full_p:get_child_text(PARTICIPANT_PROP_REQUEST_TRANSCRIPTION) then
if requestTranscriptionValue == 'true' then
local lang = full_p:get_child_text(PARTICIPANT_PROP_TRANSLATION_LANG);
occupant._transcription_enabled = true;
add_transcription(room, occupant, lang);
elseif occupant._transcription_enabled then
occupant._transcription_enabled = false;
elseif hasTranscriptionEnabled then
remove_transcription(room, occupant, nil);
end

View File

@@ -113,6 +113,11 @@ module:hook("message/bare", function(event)
return
end
if room.polls.by_id[data.pollId] ~= nil then
module:log("error", "Poll already exists: %s", data.pollId);
return;
end
local answers = {}
local compact_answers = {}
for i, name in ipairs(data.answers) do

View File

@@ -377,7 +377,7 @@ local function go_live(room)
-- if missing we assume room is live, only skip if it is marked explicitly as false
if room.jitsiMetadata and room.jitsiMetadata.visitors
and room.jitsiMetadata.visitors.live and room.jitsiMetadata.visitors.live == false then
and room.jitsiMetadata.visitors.live ~= nil and room.jitsiMetadata.visitors.live == false then
return;
end
@@ -600,7 +600,13 @@ process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_modul
go_live(event.room);
end);
host_module:hook('muc-occupant-joined', function (event)
go_live(event.room);
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
go_live(room);
end);
end