Compare commits

...

23 Commits

Author SHA1 Message Date
Hristo Terezov
9cd6e7798e 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-28 08:13:47 -05:00
Hristo Terezov
8fb280063b feat(connect): Add logging before every connect. 2024-10-17 08:46:23 -05:00
Jaya Allamsetty
1620e1e9f4 fix(face-landmarks) Ignore muted tracks while starting detection.
This fixes an issue where a user gets stuck on lobby page when they have a muted video track after the user is accepted.
2024-09-25 14:18:43 -05:00
Дамян Минков
9cb34964d2 feat(follow-me): Adds iframeAPI to activate recorder follow me. (#15134)
* feat(follow-me): Adds iframeAPI to activate recorder follow me.

* squash: suggestion.

Co-authored-by: Hristo Terezov <hristo@jitsi.org>

---------

Co-authored-by: Hristo Terezov <hristo@jitsi.org>
2024-09-17 12:19:38 -05:00
damencho
950a5d9dec fix(follow-me): Small UI fixes.
Does not allow toggling both follow me and follow me recorder. And make when locally enabled show correct status when follow me recorder is selected.
2024-09-16 14:01:30 -05:00
Дамян Минков
7ddfc182db feat(follow-me): Adds option to limit it for recorder only. (#15120)
* feat(follow-me): Adds option to limit it for recorder only.

* squash: Fix comments.

* squash: Fix comments.
2024-09-16 10:07:51 -05:00
Hristo Terezov
236e30c307 fix(devices): Do not select stored devices that are not available. 2024-09-06 11:59:42 -05:00
damencho
f629fc5545 feat(shared-video): Fixes showing thumb on the sharer side.
Fixes #15077.
2024-09-03 22:03:25 -05:00
damencho
830b458a9c feat(participants-pane): Fixes actions menu when sharing video.
We have actions menu for the video just for the local participant who shared the video.
2024-09-03 22:03:18 -05:00
Дамян Минков
fcd09bcb2f feat(shared-video): Closes confirm dialog if shown on stop. (#15065)
* feat(shared-video): Closes confirm dialog if shown on stop.

* squash: Show notification about the stopped video.
2024-08-28 12:08:13 -05:00
damencho
398cc6eca4 fix(shared-video): Fix skip showing confirm dialog for YouTube links. 2024-08-27 16:35:23 -05:00
Дамян Минков
a8333f3fb8 feat(shared-video): Shows confirmation dialog before playing video. (#15059)
* feat(shared-video): Shows confirmation dialog before playing video.

* feat(shared-video/native): created ShareVideoConfirmDialog and unified actions

* squash: Simplifies state and fixes stop and then start scenario.

* squash: Use constants everywhere.

* squash: Use helper function.

* squash: Ignore any command with not matching video URL.

---------

Co-authored-by: Calin-Teodor <calin.chitu@8x8.com>
2024-08-27 10:56:54 -05:00
Hristo Terezov
aff5ccddd0 feat(shared-video): Get allowed URL domains from config and dynamic branding. 2024-08-27 08:16:37 -05:00
Hristo Terezov
fe1f2299cf fix(shared-video): Lint errors. 2024-08-27 08:16:26 -05:00
Calin-Teodor
b6c78a528d feat(shared-video): run whitelisted urls through store 2024-08-27 08:16:14 -05:00
Hristo Terezov
ab6ea18b2f feat(shared-video): Allow only whitelisted URLs. 2024-08-27 08:15:51 -05:00
damencho
b1d65a4af3 fix(participants): Handles kicker undefined on participantKicked. 2024-08-23 12:20:39 -05:00
damencho
a26a085345 fix(visitors): Visitors raise hand to be promoted, skip notification for speak line. 2024-08-21 09:34:16 -05:00
damencho
5dfaabb7d4 fix(visitors): Do not show reactions icon in visitors join dialog. 2024-08-21 09:32:00 -05:00
Jaya Allamsetty
9bc7347d1e chore(deps) update lib-jitsi-meet.
Includes QualityController updates - 8dfcb42fea
2024-08-12 16:12:35 -04:00
Mihaela Dumitru
68803853fc fix(visitors) remove default notification (#14970) 2024-08-12 16:09:08 -04:00
Hristo Terezov
d55c065420 fix(gif): Restrict gif rendering to Giphy only 2024-08-09 11:26:25 -05:00
Hristo Terezov
8541427604 fix(giphy): Remove proxyUrl config option. 2024-08-09 11:25:06 -05:00
69 changed files with 949 additions and 306 deletions

View File

@@ -559,10 +559,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,
@@ -606,30 +606,50 @@ export default {
const { dispatch, getState } = APP.store;
const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions);
dispatch(setInitialGUMPromise(tryCreateLocalTracks.then(async tr => {
tryCreateLocalTracks.then(async tr => {
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());
// Note: Not sure if initPrejoin needs to be async. But let's wait for it just to be sure the
// tracks are added.
// 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);
// 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);
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());
}
},
@@ -2034,8 +2054,9 @@ export default {
const { dispatch } = APP.store;
return dispatch(getAvailableDevices())
.then(devices => {
APP.UI.onAvailableDevicesChanged(devices);
.then(() => {
this.updateAudioIconEnabled();
this.updateVideoIconEnabled();
});
}
@@ -2200,7 +2221,8 @@ export default {
return Promise.all(promises)
.then(() => {
APP.UI.onAvailableDevicesChanged(filteredDevices);
this.updateAudioIconEnabled();
this.updateVideoIconEnabled();
});
},

View File

@@ -1426,6 +1426,13 @@ var config = {
*/
// dynamicBrandingUrl: '',
// A list of allowed URL domains for shared video.
//
// NOTE:
// '*' is allowed value and it will allow any URL to be used for shared video. We do not recommend using '*',
// use it at your own risk!
// sharedVideoAllowedURLDomains: [ ],
// Options related to the participants pane.
// participantsPane: {
// // Enables feature
@@ -1756,8 +1763,6 @@ var config = {
// tileTime: 5000,
// // Limit results by rating: g, pg, pg-13, r. Default value: g.
// rating: 'pg',
// // The proxy server url for giphy requests in the web app.
// proxyUrl: 'https://giphy-proxy.example.com',
// },
// Logging

View File

@@ -439,11 +439,14 @@
"shareScreenWarningD2": "you need to stop audio sharing, start screen sharing and check the \"share audio\" option.",
"shareScreenWarningH1": "If you want to share just your screen:",
"shareScreenWarningTitle": "You need to stop audio sharing before sharing your screen",
"shareVideoLinkError": "Please provide a correct video link.",
"shareVideoConfirmPlay": "Youre about to open an external website. Do you want to continue?",
"shareVideoConfirmPlayTitle": "{{name}} has shared a video with you.",
"shareVideoLinkError": "Oops, this video cannot be played.",
"shareVideoLinkStopped": "The video from {{name}} was stopped.",
"shareVideoTitle": "Share video",
"shareYourScreen": "Share your screen",
"shareYourScreenDisabled": "Screen sharing disabled.",
"sharedVideoDialogError": "Error: Invalid URL",
"sharedVideoDialogError": "Error: Invalid or forbidden URL",
"sharedVideoLinkPlaceholder": "YouTube link or direct video link",
"show": "Show",
"start": "Start ",
@@ -1092,6 +1095,7 @@
"desktopShareWarning": "You need to restart the screen share for the new settings to take effect.",
"devices": "Devices",
"followMe": "Everyone follows me",
"followMeRecorder": "Recorder follows me",
"framesPerSecond": "frames-per-second",
"incomingMessage": "Incoming message",
"language": "Language",
@@ -1503,7 +1507,6 @@
"labelTooltip": "Number of visitors: {{count}}",
"notification": {
"demoteDescription": "Sent here by {{actor}}, raise your hand to participate",
"description": "To participate raise your hand",
"noMainParticipantsDescription": "A participant needs to start the meeting. Please try again in a bit.",
"noMainParticipantsTitle": "This meeting hasnt started yet.",
"noVisitorLobby": "You cannot join while there is a lobby enabled for the meeting.",

View File

@@ -19,6 +19,7 @@ import {
sendTones,
setAssumedBandwidthBps,
setFollowMe,
setFollowMeRecorder,
setLocalSubject,
setPassword,
setSubject
@@ -322,15 +323,18 @@ function initCommands() {
APP.store.dispatch(setAssumedBandwidthBps(value));
},
'set-follow-me': value => {
'set-follow-me': (value, recorderOnly) => {
if (value) {
sendAnalytics(createApiEvent('follow.me.set'));
sendAnalytics(createApiEvent('follow.me.set', {
recorderOnly
}));
} else {
sendAnalytics(createApiEvent('follow.me.unset'));
sendAnalytics(createApiEvent('follow.me.unset', {
recorderOnly
}));
}
APP.store.dispatch(setFollowMe(value));
APP.store.dispatch(recorderOnly ? setFollowMeRecorder(value) : setFollowMe(value));
},
'set-large-video-participant': (participantId, videoType) => {
const { getState, dispatch } = APP.store;

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

9
package-lock.json generated
View File

@@ -61,7 +61,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/v1845.0.0+515a927c/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-8100",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -12489,8 +12489,7 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1845.0.0+515a927c/lib-jitsi-meet.tgz",
"integrity": "sha512-UzT6skfD8wqs2695oMi3B8SxcImhG2SIYwc1xM+z9pVlJ4MAOWsLc7flCBXSnpnUTK3b5AjCOot3cRqeMibNcQ==",
"resolved": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#8dfcb42fea0a58d653469d6bb7e2d84e33b566d5",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -27986,8 +27985,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1845.0.0+515a927c/lib-jitsi-meet.tgz",
"integrity": "sha512-UzT6skfD8wqs2695oMi3B8SxcImhG2SIYwc1xM+z9pVlJ4MAOWsLc7flCBXSnpnUTK3b5AjCOot3cRqeMibNcQ==",
"version": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#8dfcb42fea0a58d653469d6bb7e2d84e33b566d5",
"from": "lib-jitsi-meet@https://github.com/jitsi/lib-jitsi-meet#release-8100",
"requires": {
"@jitsi/js-utils": "2.2.1",
"@jitsi/logger": "2.0.2",

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/v1845.0.0+515a927c/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-8100",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",

View File

@@ -15,6 +15,7 @@ import {
authenticateAndUpgradeRole,
cancelLogin
} from '../../actions.web';
import logger from '../../logger';
/**
* The type of the React {@code Component} props of {@link LoginDialog}.
@@ -134,6 +135,7 @@ class LoginDialog extends Component<IProps, IState> {
if (conference) {
dispatch(authenticateAndUpgradeRole(jid, password, conference));
} else {
logger.info('Dispatching connect from LoginDialog.');
dispatch(connect(jid, password));
}
}

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

@@ -56,7 +56,7 @@ export const CONFERENCE_LEFT = 'CONFERENCE_LEFT';
/**
* The type of (redux) action which signals that the conference is out of focus.
* For example, if the user navigates to the Chat screen.
*
*
* {
* type: CONFERENCE_BLURRED,
* }
@@ -65,7 +65,7 @@ export const CONFERENCE_BLURRED = 'CONFERENCE_BLURRED';
/**
* The type of (redux) action which signals that the conference is in focus.
*
*
* {
* type: CONFERENCE_FOCUSED,
* }
@@ -258,6 +258,17 @@ export const SEND_TONES = 'SEND_TONES';
*/
export const SET_FOLLOW_ME = 'SET_FOLLOW_ME';
/**
* The type of (redux) action which updates the current known status of the
* Follow Me feature that is used only by the recorder.
*
* {
* type: SET_FOLLOW_ME_RECORDER,
* enabled: boolean
* }
*/
export const SET_FOLLOW_ME_RECORDER = 'SET_FOLLOW_ME_RECORDER';
/**
* The type of (redux) action which sets the obfuscated room name.
*
@@ -338,7 +349,7 @@ export const SET_START_MUTED_POLICY = 'SET_START_MUTED_POLICY';
/**
* The type of (redux) action which updates the assumed bandwidth bps.
*
*
* {
* type: SET_ASSUMED_BANDWIDTH_BPS,
* assumedBandwidthBps: number

View File

@@ -61,6 +61,7 @@ import {
SEND_TONES,
SET_ASSUMED_BANDWIDTH_BPS,
SET_FOLLOW_ME,
SET_FOLLOW_ME_RECORDER,
SET_OBFUSCATED_ROOM,
SET_PASSWORD,
SET_PASSWORD_FAILED,
@@ -840,6 +841,22 @@ export function setFollowMe(enabled: boolean) {
};
}
/**
* Enables or disables the Follow Me feature used only for the recorder.
*
* @param {boolean} enabled - Whether Follow Me should be enabled and used only by the recorder.
* @returns {{
* type: SET_FOLLOW_ME_RECORDER,
* enabled: boolean
* }}
*/
export function setFollowMeRecorder(enabled: boolean) {
return {
type: SET_FOLLOW_ME_RECORDER,
enabled
};
}
/**
* Enables or disables the Mute reaction sounds feature.
*
@@ -1069,6 +1086,7 @@ export function redirect(vnode: string, focusJid: string, username: string) {
})
.then(() => {
dispatch(conferenceWillInit());
logger.info(`Dispatching connect from redirect (visitor = ${Boolean(vnode)}).`);
return dispatch(connect());
})

View File

@@ -614,37 +614,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 +633,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

@@ -26,6 +26,7 @@ import {
P2P_STATUS_CHANGED,
SET_ASSUMED_BANDWIDTH_BPS,
SET_FOLLOW_ME,
SET_FOLLOW_ME_RECORDER,
SET_OBFUSCATED_ROOM,
SET_PASSWORD,
SET_PENDING_SUBJECT_CHANGE,
@@ -159,6 +160,7 @@ export interface IConferenceState {
e2eeSupported?: boolean;
error?: Error;
followMeEnabled?: boolean;
followMeRecorderEnabled?: boolean;
joining?: IJitsiConference;
leaving?: IJitsiConference;
lobbyWaitingForHost?: boolean;
@@ -251,6 +253,12 @@ ReducerRegistry.register<IConferenceState>('features/base/conference',
case SET_FOLLOW_ME:
return set(state, 'followMeEnabled', action.enabled);
case SET_FOLLOW_ME_RECORDER:
return { ...state,
followMeRecorderEnabled: action.enabled,
followMeEnabled: action.enabled
};
case SET_START_REACTIONS_MUTED:
return set(state, 'startReactionsMuted', action.muted);

View File

@@ -375,7 +375,6 @@ export interface IConfig {
giphy?: {
displayMode?: 'all' | 'tile' | 'chat';
enabled?: boolean;
proxyUrl?: string;
rating?: 'g' | 'pg' | 'pg-13' | 'r';
sdkKey?: string;
tileTime?: number;
@@ -526,6 +525,7 @@ export interface IConfig {
hideLobbyButton?: boolean;
};
serviceUrl?: string;
sharedVideoAllowedURLDomains?: Array<string>;
sipInviteUrl?: string;
speakerStats?: {
disableSearch?: boolean;

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

@@ -547,7 +547,7 @@ export function participantKicked(kicker: any, kicked: any) {
const state = getState();
const localParticipant = getLocalParticipant(state);
const kickedId = kicked.getId();
const kickerId = kicker.getId();
const kickerId = kicker?.getId();
dispatch({
type: PARTICIPANT_KICKED,
@@ -555,7 +555,7 @@ export function participantKicked(kicker: any, kicked: any) {
kicker: kickerId
});
if (kicked.isReplaced?.() || kickerId === localParticipant?.id) {
if (kicked.isReplaced?.() || !kickerId || kickerId === localParticipant?.id) {
return;
}

View File

@@ -8,6 +8,7 @@ import {
isTrackStreamingStatusInactive
} from '../../../connection-indicator/functions';
import SharedVideo from '../../../shared-video/components/native/SharedVideo';
import { isSharedVideoEnabled } from '../../../shared-video/functions';
import { IStateful } from '../../app/types';
import Avatar from '../../avatar/components/Avatar';
import { translate } from '../../i18n/functions';
@@ -236,7 +237,8 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
_isConnectionInactive: isTrackStreamingStatusInactive(videoTrack),
_isSharedVideoParticipant: isSharedVideoParticipant(participant),
_participantName: getParticipantDisplayName(state, participantId),
_renderVideo: shouldRenderParticipantVideo(state, participantId) && !disableVideo,
_renderVideo: shouldRenderParticipantVideo(state, participantId) && !disableVideo
&& isSharedVideoEnabled(state),
_videoTrack: videoTrack
};
}

View File

@@ -33,7 +33,8 @@ StateListenerRegistry.register(
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].raisedHandsQueue,
/* listener */ (raisedHandsQueue, store) => {
if (raisedHandsQueue.length && isNextToSpeak(store.getState()) && !hasBeenNotified(store.getState())) {
if (raisedHandsQueue.length && isNextToSpeak(store.getState()) && !hasBeenNotified(store.getState())
&& !store.getState()['features/visitors'].iAmVisitor) { // visitors raise hand to be promoted
_notifyNextSpeakerInRaisedHandQueue(store);
}
}

View File

@@ -1,14 +1,21 @@
import React, { Component, ReactNode } from 'react';
import { toArray } from 'react-emoji-render';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import GifMessage from '../../../../chat/components/web/GifMessage';
import { GIF_PREFIX } from '../../../../gifs/constants';
import { isGifMessage } from '../../../../gifs/functions.web';
import { isGifEnabled, isGifMessage } from '../../../../gifs/functions.web';
import Linkify from './Linkify';
interface IProps {
/**
* Whether the gifs are enabled or not.
*/
gifEnabled: boolean;
/**
* The body of the message.
*/
@@ -43,11 +50,11 @@ class Message extends Component<IProps> {
// Tokenize the text in order to avoid emoji substitution for URLs
const tokens = text ? text.split(' ') : [];
const content = [];
const { gifEnabled } = this.props;
// check if the message is a GIF
if (isGifMessage(text)) {
if (gifEnabled && isGifMessage(text)) {
const url = text.substring(GIF_PREFIX.length, text.length - 1);
content.push(<GifMessage
@@ -93,4 +100,16 @@ class Message extends Component<IProps> {
}
}
export default Message;
/**
* Maps part of the redux state to the props of this component.
*
* @param {IReduxState} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
gifEnabled: isGifEnabled(state)
};
}
export default connect(_mapStateToProps)(Message);

View File

@@ -178,18 +178,20 @@ function _getUserSelectedDeviceId(options: {
replacement = ''
} = options;
// If there is no label at all, there is no need to fall back to checking
// the label for a fuzzy match.
if (!userSelectedDeviceLabel || !userSelectedDeviceId) {
return userSelectedDeviceId;
if (userSelectedDeviceId) {
const foundMatchingBasedonDeviceId = availableDevices?.find(
candidate => candidate.deviceId === userSelectedDeviceId);
// Prioritize matching the deviceId
if (foundMatchingBasedonDeviceId) {
return userSelectedDeviceId;
}
}
const foundMatchingBasedonDeviceId = availableDevices?.find(
candidate => candidate.deviceId === userSelectedDeviceId);
// Prioritize matching the deviceId
if (foundMatchingBasedonDeviceId) {
return userSelectedDeviceId;
// If there is no label at all, there is no need to fall back to checking
// the label for a fuzzy match.
if (!userSelectedDeviceLabel) {
return;
}
const strippedDeviceLabel

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

View File

@@ -6,7 +6,7 @@ import { IReduxState } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { translate } from '../../../base/i18n/functions';
import Linkify from '../../../base/react/components/native/Linkify';
import { isGifMessage } from '../../../gifs/functions.native';
import { isGifEnabled, isGifMessage } from '../../../gifs/functions.native';
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL } from '../../constants';
import {
getCanReplyToMessage,
@@ -32,7 +32,7 @@ class ChatMessage extends Component<IChatMessageProps> {
* @inheritdoc
*/
render() {
const { message, knocking } = this.props;
const { gifEnabled, message, knocking } = this.props;
const localMessage = message.messageType === MESSAGE_TYPE_LOCAL;
const { privateMessage, lobbyChat } = message;
@@ -82,7 +82,7 @@ class ChatMessage extends Component<IChatMessageProps> {
<View style = { messageBubbleStyle }>
<View style = { styles.textWrapper as ViewStyle } >
{ this._renderDisplayName() }
{ isGifMessage(messageText)
{ gifEnabled && isGifMessage(messageText)
? <GifMessage message = { messageText } />
: (
<Linkify
@@ -210,6 +210,7 @@ class ChatMessage extends Component<IChatMessageProps> {
function _mapStateToProps(state: IReduxState, { message }: IChatMessageProps) {
return {
canReply: getCanReplyToMessage(state, message),
gifEnabled: isGifEnabled(state),
knocking: state['features/lobby'].knocking
};
}

View File

@@ -25,7 +25,7 @@ import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { playSound, registerSound, unregisterSound } from '../base/sounds/actions';
import { addGif } from '../gifs/actions';
import { GIF_PREFIX } from '../gifs/constants';
import { getGifDisplayMode, isGifMessage } from '../gifs/function.any';
import { getGifDisplayMode, isGifEnabled, isGifMessage } from '../gifs/function.any';
import { showMessageNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { resetNbUnreadPollsMessages } from '../polls/actions';
@@ -314,7 +314,7 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
function _onConferenceMessageReceived(store: IStore, { displayName, id, isGuest, message, timestamp, privateMessage }: {
displayName?: string; id: string; isGuest?: boolean;
message: string; privateMessage: boolean; timestamp: number; }) {
const isGif = isGifMessage(message);
const isGif = isGifEnabled(store.getState()) && isGifMessage(message);
if (isGif) {
_handleGifMessageReceived(store, id, message);

View File

@@ -44,6 +44,11 @@ export interface IChatMessageProps extends WithTranslation {
*/
canReply?: boolean;
/**
* Whether gifs are enabled or not.
*/
gifEnabled?: boolean;
/**
* Whether current participant is currently knocking in the lobby room.
*/

View File

@@ -49,9 +49,11 @@ export function setupInitialDevices() {
/**
* 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']) => {
const room = getBackendSafeRoomName(getState()['features/base/conference'].room);
@@ -59,7 +61,8 @@ export function init() {
// from the old app (at the moment of writing).
return dispatch(setupInitialDevices()).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

@@ -157,6 +157,7 @@ export interface IDynamicBrandingState {
logoImageUrl: string;
muiBrandedTheme?: boolean;
premeetingBackground: string;
sharedVideoAllowedURLDomains?: Array<string>;
showGiphyIntegration?: boolean;
useDynamicBrandingData: boolean;
virtualBackgrounds: Array<Image>;
@@ -181,6 +182,7 @@ ReducerRegistry.register<IDynamicBrandingState>(STORE_NAME, (state = DEFAULT_STA
logoImageUrl,
muiBrandedTheme,
premeetingBackground,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
virtualBackgrounds
} = action.value;
@@ -198,6 +200,7 @@ ReducerRegistry.register<IDynamicBrandingState>(STORE_NAME, (state = DEFAULT_STA
logoImageUrl,
muiBrandedTheme,
premeetingBackground,
sharedVideoAllowedURLDomains,
showGiphyIntegration,
customizationFailed: false,
customizationReady: true,

View File

@@ -193,8 +193,8 @@ class FaceLandmarksDetector {
const state = getState();
const localVideoTrack = track || getLocalVideoTrack(state['features/base/tracks']);
if (localVideoTrack === undefined) {
logger.warn('Face landmarks detection is disabled due to missing local track.');
if (!localVideoTrack || localVideoTrack.jitsiTrack?.isMuted()) {
logger.debug('Face landmarks detection is disabled due to missing local track.');
return;
}

View File

@@ -7,15 +7,18 @@ import {
* Sets the current moderator id or clears it.
*
* @param {?string} id - The Follow Me moderator participant id.
* @param {?boolean} forRecorder - Whether this is command only for recorder.
* @returns {{
* type: SET_FOLLOW_ME_MODERATOR,
* id, string
* id: string,
* forRecorder: boolean
* }}
*/
export function setFollowMeModerator(id?: string) {
export function setFollowMeModerator(id?: string, forRecorder?: boolean) {
return {
type: SET_FOLLOW_ME_MODERATOR,
id
id,
forRecorder
};
}

View File

@@ -13,3 +13,16 @@ export function isFollowMeActive(stateful: IStateful) {
return Boolean(state['features/follow-me'].moderator);
}
/**
* Returns true if follow me is active only for the recorder and false otherwise.
*
* @param {Object|Function} stateful - Object or function that can be resolved
* to the Redux state.
* @returns {boolean} - True if follow me is active and false otherwise.
*/
export function isFollowMeRecorderActive(stateful: IStateful) {
const state = toState(stateful);
return Boolean(state['features/follow-me'].recorder);
}

View File

@@ -128,7 +128,7 @@ function _onFollowMeCommand(attributes: any = {}, id: string, store: IStore) {
}
if (!isFollowMeActive(state)) {
store.dispatch(setFollowMeModerator(id));
store.dispatch(setFollowMeModerator(id, attributes.recorder));
}
// just a command that follow me was turned off
@@ -138,6 +138,11 @@ function _onFollowMeCommand(attributes: any = {}, id: string, store: IStore) {
return;
}
// when recorder flag is on, follow me is handled only on recorder side
if (attributes.recorder && !store.getState()['features/base/config'].iAmRecorder) {
return;
}
const oldState = state['features/follow-me'].state || {};
store.dispatch(setFollowMeState(attributes));

View File

@@ -8,6 +8,7 @@ import {
export interface IFollowMeState {
moderator?: string;
recorder?: boolean;
state?: {
[key: string]: string;
};
@@ -24,9 +25,12 @@ ReducerRegistry.register<IFollowMeState>(
case SET_FOLLOW_ME_MODERATOR: {
let newState = set(state, 'moderator', action.id);
if (!action.id) {
if (action.id) {
newState = set(newState, 'recorder', action.forRecorder);
} else {
// clear the state if feature becomes disabled
newState = set(newState, 'state', undefined);
newState = set(newState, 'recorder', undefined);
}
return newState;

View File

@@ -88,6 +88,7 @@ function _getFollowMeState(state: IReduxState) {
const stageFilmstrip = isStageFilmstripEnabled(state);
return {
recorder: state['features/base/conference'].followMeRecorderEnabled,
filmstripVisible: state['features/filmstrip'].visible,
maxStageParticipants: stageFilmstrip ? state['features/base/settings'].maxStageParticipants : undefined,
nextOnStage: pinnedParticipant?.id,

View File

@@ -9,7 +9,7 @@ import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import Input from '../../../base/ui/components/native/Input';
import { sendMessage } from '../../../chat/actions.any';
import { goBack } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { formatGifUrlMessage, getGifRating, getGifUrl, getGiphyProxyUrl } from '../../functions.native';
import { formatGifUrlMessage, getGifRating, getGifUrl } from '../../functions.native';
import GifsMenuFooter from './GifsMenuFooter';
import styles from './styles';
@@ -19,8 +19,6 @@ const GifsMenu = () => {
const dispatch = useDispatch();
const { t } = useTranslation();
const rating = useSelector(getGifRating) as GiphyRating;
const proxyUrl = useSelector(getGiphyProxyUrl);
const options = {
mediaType: GiphyMediaType.Gif,
limit: 20,
@@ -35,7 +33,7 @@ const GifsMenu = () => {
});
const sendGif = useCallback(e => {
const url = getGifUrl(e.nativeEvent.media, proxyUrl);
const url = getGifUrl(e.nativeEvent.media);
sendAnalytics(createGifSentEvent());

View File

@@ -1,4 +1,4 @@
import { GiphyFetch, TrendingOptions, setServerUrl } from '@giphy/js-fetch-api';
import { GiphyFetch, TrendingOptions } from '@giphy/js-fetch-api';
import { Grid } from '@giphy/react-components';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,8 +20,7 @@ import {
formatGifUrlMessage,
getGifAPIKey,
getGifRating,
getGifUrl,
getGiphyProxyUrl
getGifUrl
} from '../../function.any';
const OVERFLOW_DRAWER_PADDING = 16;
@@ -104,7 +103,6 @@ function GifsMenu({ columns = 2, parent }: IProps) {
= parent === IReactionsMenuParent.OverflowDrawer || parent === IReactionsMenuParent.OverflowMenu;
const { clientWidth } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
const rating = useSelector(getGifRating);
const proxyUrl = useSelector(getGiphyProxyUrl);
const fetchGifs = useCallback(async (offset = 0) => {
const options: TrendingOptions = {
@@ -126,7 +124,7 @@ function GifsMenu({ columns = 2, parent }: IProps) {
const handleGifClick = useCallback((gif, e) => {
e?.stopPropagation();
const url = getGifUrl(gif, proxyUrl);
const url = getGifUrl(gif);
sendAnalytics(createGifSentEvent());
batch(() => {
@@ -189,12 +187,6 @@ function GifsMenu({ columns = 2, parent }: IProps) {
// This fixes that.
useEffect(() => setSearchKey(''), []);
useEffect(() => {
if (proxyUrl) {
setServerUrl(proxyUrl);
}
}, []);
const onInputKeyPress = useCallback((e: React.KeyboardEvent) => {
e.stopPropagation();
}, []);

View File

@@ -33,15 +33,6 @@ export function getGifRating(state: IReduxState) {
return getGifConfig(state).rating || GIF_DEFAULT_RATING;
}
/**
* Get the Giphy proxy url.
*
* @param {IReduxState} state - Redux state.
* @returns {string}
*/
export function getGiphyProxyUrl(state: IReduxState) {
return getGifConfig(state).proxyUrl;
}
/**
* Gets the URL of the GIF for the given participant or null if there's none.
@@ -54,6 +45,26 @@ export function getGifForParticipant(state: IReduxState, participantId: string):
return isGifEnabled(state) ? state['features/gifs'].gifList.get(participantId) || {} : {};
}
/**
* Returns true if a given URL is allowed to be rendered as gif and false otherwise.
*
* @param {string} url - The URL to be validated.
* @returns {boolean} - True if a given URL is allowed to be rendered as gif and false otherwise.
*/
export function isGifUrlAllowed(url: string) {
let hostname: string | undefined;
try {
const urlObject = new URL(url);
hostname = urlObject?.hostname;
} catch (_error) {
return false;
}
return hostname === 'i.giphy.com';
}
/**
* Whether or not the message is a GIF message.
*
@@ -61,26 +72,23 @@ export function getGifForParticipant(state: IReduxState, participantId: string):
* @returns {boolean}
*/
export function isGifMessage(message: string) {
const url = message.substring(GIF_PREFIX.length, message.length - 1);
return message.trim().toLowerCase()
.startsWith(GIF_PREFIX);
.startsWith(GIF_PREFIX) && isGifUrlAllowed(url);
}
/**
* Returns the url of the gif selected in the gifs menu.
*
* @param {Object} gif - The gif data.
* @param {string} proxyUrl - The proxy server url.
* @returns {boolean}
*/
export function getGifUrl(gif?: { data?: { embed_url: string; }; embed_url?: string; }, proxyUrl?: string) {
export function getGifUrl(gif?: { data?: { embed_url: string; }; embed_url?: string; }) {
const embedUrl = gif?.embed_url || gif?.data?.embed_url || '';
const idx = embedUrl.lastIndexOf('/');
const id = embedUrl.substr(idx + 1);
if (proxyUrl) {
return `${proxyUrl}gifs/id/${id}`;
}
return `https://i.giphy.com/media/${id}/giphy.gif`;
}

View File

@@ -206,6 +206,8 @@ export function startKnocking() {
const state = getState();
const { membersOnly } = state['features/base/conference'];
logger.info(`Lobby starting knocking (membersOnly = ${membersOnly})`);
if (!membersOnly) {
// no membersOnly, this means we got lobby screen shown as someone

View File

@@ -280,7 +280,7 @@ function MeetingParticipantItem({
</>
}
{!overflowDrawer && (_localVideoOwner || _participant?.fakeParticipant) && (
{!overflowDrawer && (_localVideoOwner && _participant?.fakeParticipant) && (
<ParticipantActionEllipsis
accessibilityLabel = { participantActionEllipsisLabel }
onClick = { onContextMenu } />

View File

@@ -209,6 +209,7 @@ export function joinConference(options?: Object, ignoreJoiningInProgress = false
options && dispatch(updateConfig(options));
logger.info('Dispatching connect from joinConference.');
dispatch(connect(jid, password))
.catch(() => {
// There is nothing to do here. This is handled and dispatched in base/connection/actions.
@@ -257,6 +258,8 @@ export function joinConferenceWithoutAudio() {
}
}
logger.info('Dispatching joinConference action with startSilent=true from joinConferenceWithoutAudio.');
dispatch(joinConference({
startSilent: true
}, true));

View File

@@ -35,6 +35,7 @@ import {
isJoinByPhoneDialogVisible,
isPrejoinDisplayNameVisible
} from '../../functions';
import logger from '../../logger';
import { hasDisplayName } from '../../utils';
import JoinByPhoneDialog from './dialogs/JoinByPhoneDialog';
@@ -256,6 +257,9 @@ const Prejoin = ({
return;
}
logger.info('Prejoin join button clicked.');
joinConference();
};
@@ -337,6 +341,7 @@ const Prejoin = ({
&& (e.key === ' '
|| e.key === 'Enter')) {
e.preventDefault();
logger.info('Prejoin joinConferenceWithoutAudio dispatched on a key pressed.');
joinConferenceWithoutAudio();
}
};
@@ -352,7 +357,10 @@ const Prejoin = ({
testId: 'prejoin.joinWithoutAudio',
icon: IconVolumeOff,
label: t('prejoin.joinWithoutAudio'),
onClick: joinConferenceWithoutAudio,
onClick: () => {
logger.info('Prejoin join conference without audio pressed.');
joinConferenceWithoutAudio();
},
onKeyPress: onJoinConferenceWithoutAudioKeyPress
};
@@ -379,6 +387,7 @@ const Prejoin = ({
*/
const onInputKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
logger.info('Dispatching join conference on Enter key press from the prejoin screen.');
joinConference();
}
};

View File

@@ -5,6 +5,7 @@ import { setTokenAuthUrlSuccess } from '../authentication/actions.web';
import { isTokenAuthEnabled } from '../authentication/functions';
import {
setFollowMe,
setFollowMeRecorder,
setStartMutedPolicy,
setStartReactionsMuted
} from '../base/conference/actions';
@@ -165,6 +166,10 @@ export function submitModeratorTab(newState: any) {
dispatch(setFollowMe(newState.followMeEnabled));
}
if (newState.followMeRecorderEnabled !== currentState.followMeRecorderEnabled) {
dispatch(setFollowMeRecorder(newState.followMeRecorderEnabled));
}
if (newState.startReactionsMuted !== currentState.startReactionsMuted) {
batch(() => {
// updating settings we want to update and backend (notify the rest of the participants)

View File

@@ -2,7 +2,12 @@ import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { setFollowMe, setStartMutedPolicy, setStartReactionsMuted } from '../../../base/conference/actions';
import {
setFollowMe,
setFollowMeRecorder,
setStartMutedPolicy,
setStartReactionsMuted
} from '../../../base/conference/actions';
import { updateSettings } from '../../../base/settings/actions';
import Switch from '../../../base/ui/components/native/Switch';
import { getModeratorTabProps } from '../../functions.native';
@@ -13,7 +18,10 @@ import FormSection from './FormSection';
const ModeratorSection = () => {
const dispatch = useDispatch();
const {
followMeActive,
followMeEnabled,
followMeRecorderActive,
followMeRecorderEnabled,
startAudioMuted,
startVideoMuted,
startReactionsMuted
@@ -35,29 +43,45 @@ const ModeratorSection = () => {
dispatch(setFollowMe(Boolean(enabled)));
}, [ dispatch, setFollowMe ]);
const onFollowMeRecorderToggled = useCallback((enabled?: boolean) => {
dispatch(setFollowMeRecorder(Boolean(enabled)));
}, [ dispatch, setFollowMeRecorder ]);
const onStartReactionsMutedToggled = useCallback((enabled?: boolean) => {
dispatch(setStartReactionsMuted(Boolean(enabled), true));
dispatch(updateSettings({ soundsReactions: enabled }));
}, [ dispatch, updateSettings, setStartReactionsMuted ]);
const followMeRecorderChecked = followMeRecorderEnabled && !followMeRecorderActive;
const moderationSettings = useMemo(() => {
const moderation = [
{
disabled: false,
label: 'settings.startAudioMuted',
state: startAudioMuted,
onChange: onStartAudioMutedToggled
},
{
disabled: false,
label: 'settings.startVideoMuted',
state: startVideoMuted,
onChange: onStartVideoMutedToggled
},
{
disabled: followMeActive || followMeRecorderActive,
label: 'settings.followMe',
state: followMeEnabled,
state: followMeEnabled && !followMeActive && !followMeRecorderChecked,
onChange: onFollowMeToggled
},
{
disabled: followMeRecorderActive || followMeActive,
label: 'settings.followMeRecorder',
state: followMeRecorderChecked,
onChange: onFollowMeRecorderToggled
},
{
disabled: false,
label: 'settings.startReactionsMuted',
state: startReactionsMuted,
onChange: onStartReactionsMutedToggled
@@ -72,19 +96,26 @@ const ModeratorSection = () => {
}, [ startAudioMuted,
startVideoMuted,
followMeEnabled,
followMeRecorderEnabled,
disableReactionsModeration,
onStartAudioMutedToggled,
onStartVideoMutedToggled,
onFollowMeToggled,
onFollowMeRecorderToggled,
onStartReactionsMutedToggled,
startReactionsMuted ]);
return (
<FormSection
label = 'settings.playSounds'>
{
moderationSettings.map(({ label, state, onChange }) => (
moderationSettings.map(({ label, state, onChange, disabled }) => (
<FormRow
key = { label }
label = { label }>
<Switch
checked = { Boolean(state) }
disabled = { disabled }
onChange = { onChange } />
</FormRow>
))

View File

@@ -34,6 +34,16 @@ export interface IProps extends AbstractDialogTabProps, WithTranslation {
*/
followMeEnabled: boolean;
/**
* Whether follow me for recorder is currently active (enabled by some other participant).
*/
followMeRecorderActive: boolean;
/**
* Whether the user has selected the Follow Me Recorder feature to be enabled.
*/
followMeRecorderEnabled: boolean;
/**
* Whether or not the user has selected the Start Audio Muted feature to be
* enabled.
@@ -92,6 +102,7 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
this._onStartVideoMutedChanged = this._onStartVideoMutedChanged.bind(this);
this._onStartReactionsMutedChanged = this._onStartReactionsMutedChanged.bind(this);
this._onFollowMeEnabledChanged = this._onFollowMeEnabledChanged.bind(this);
this._onFollowMeRecorderEnabledChanged = this._onFollowMeRecorderEnabledChanged.bind(this);
}
/**
@@ -139,7 +150,24 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
* @returns {void}
*/
_onFollowMeEnabledChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
super._onChange({ followMeEnabled: checked });
super._onChange({
followMeEnabled: checked,
followMeRecorderEnabled: checked ? false : undefined
});
}
/**
* Callback invoked to select if follow-me for recorder mode should be activated.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onFollowMeRecorderEnabledChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
super._onChange({
followMeEnabled: checked ? false : undefined,
followMeRecorderEnabled: checked
});
}
/**
@@ -153,6 +181,8 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
disableReactionsModeration,
followMeActive,
followMeEnabled,
followMeRecorderActive,
followMeRecorderEnabled,
startAudioMuted,
startVideoMuted,
startReactionsMuted,
@@ -160,6 +190,8 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
} = this.props;
const classes = withStyles.getClasses(this.props);
const followMeRecorderChecked = followMeRecorderEnabled && !followMeRecorderActive;
return (
<div
className = { `moderator-tab ${classes.container}` }
@@ -180,12 +212,19 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
name = 'start-video-muted'
onChange = { this._onStartVideoMutedChanged } />
<Checkbox
checked = { followMeEnabled && !followMeActive }
checked = { followMeEnabled && !followMeActive && !followMeRecorderChecked }
className = { classes.checkbox }
disabled = { followMeActive }
disabled = { followMeActive || followMeRecorderActive }
label = { t('settings.followMe') }
name = 'follow-me'
onChange = { this._onFollowMeEnabledChanged } />
<Checkbox
checked = { followMeRecorderChecked }
className = { classes.checkbox }
disabled = { followMeRecorderActive || followMeActive }
label = { t('settings.followMeRecorder') }
name = 'follow-me-recorder'
onChange = { this._onFollowMeRecorderEnabledChanged } />
{ !disableReactionsModeration
&& <Checkbox
checked = { startReactionsMuted }

View File

@@ -253,6 +253,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
return {
...newProps,
followMeEnabled: tabState?.followMeEnabled,
followMeRecorderEnabled: tabState?.followMeRecorderEnabled,
startAudioMuted: tabState?.startAudioMuted,
startVideoMuted: tabState?.startVideoMuted,
startReactionsMuted: tabState?.startReactionsMuted

View File

@@ -9,7 +9,7 @@ import { toState } from '../base/redux/functions';
import { getHideSelfView } from '../base/settings/functions.any';
import { parseStandardURIString } from '../base/util/uri';
import { isStageFilmstripEnabled } from '../filmstrip/functions';
import { isFollowMeActive } from '../follow-me/functions';
import { isFollowMeActive, isFollowMeRecorderActive } from '../follow-me/functions';
import { isPrejoinEnabledInConfig } from '../prejoin/functions';
import { isReactionsEnabled } from '../reactions/functions.any';
import { iAmVisitor } from '../visitors/functions';
@@ -133,12 +133,14 @@ export function getModeratorTabProps(stateful: IStateful) {
const {
conference,
followMeEnabled,
followMeRecorderEnabled,
startAudioMutedPolicy,
startVideoMutedPolicy,
startReactionsMuted
} = state['features/base/conference'];
const { disableReactionsModeration } = state['features/base/config'];
const followMeActive = isFollowMeActive(state);
const followMeRecorderActive = isFollowMeRecorderActive(state);
const showModeratorSettings = shouldShowModeratorSettings(state);
// The settings sections to display.
@@ -147,6 +149,8 @@ export function getModeratorTabProps(stateful: IStateful) {
disableReactionsModeration: Boolean(disableReactionsModeration),
followMeActive: Boolean(conference && followMeActive),
followMeEnabled: Boolean(conference && followMeEnabled),
followMeRecorderActive: Boolean(conference && followMeRecorderActive),
followMeRecorderEnabled: Boolean(conference && followMeRecorderEnabled),
startReactionsMuted: Boolean(conference && startReactionsMuted),
startAudioMuted: Boolean(conference && startAudioMutedPolicy),
startVideoMuted: Boolean(conference && startVideoMutedPolicy)

View File

@@ -19,6 +19,16 @@ export const SET_SHARED_VIDEO_STATUS = 'SET_SHARED_VIDEO_STATUS';
*/
export const RESET_SHARED_VIDEO_STATUS = 'RESET_SHARED_VIDEO_STATUS';
/**
* The type of the action which marks that the user had confirmed to play video.
*
* {
* type: SET_CONFIRM_SHOW_VIDEO
* }
*/
export const SET_CONFIRM_SHOW_VIDEO = 'SET_CONFIRM_SHOW_VIDEO';
/**
* The type of the action which signals to disable or enable the shared video
* button.
@@ -28,3 +38,12 @@ export const RESET_SHARED_VIDEO_STATUS = 'RESET_SHARED_VIDEO_STATUS';
* }
*/
export const SET_DISABLE_BUTTON = 'SET_DISABLE_BUTTON';
/**
* The type of the action which sets an array of whitelisted urls.
*
* {
* type: SET_ALLOWED_URL_DOMAINS
* }
*/
export const SET_ALLOWED_URL_DOMAINS = 'SET_ALLOWED_URL_DOMAINS';

View File

@@ -1,10 +1,33 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { openDialog } from '../base/dialog/actions';
import { hideDialog, openDialog } from '../base/dialog/actions';
import { getLocalParticipant } from '../base/participants/functions';
import { RESET_SHARED_VIDEO_STATUS, SET_SHARED_VIDEO_STATUS } from './actionTypes';
import { SharedVideoDialog } from './components';
import {
RESET_SHARED_VIDEO_STATUS,
SET_ALLOWED_URL_DOMAINS,
SET_CONFIRM_SHOW_VIDEO,
SET_SHARED_VIDEO_STATUS
} from './actionTypes';
import { ShareVideoConfirmDialog, SharedVideoDialog } from './components';
import { PLAYBACK_START, PLAYBACK_STATUSES } from './constants';
import { isSharedVideoEnabled, sendShareVideoCommand } from './functions';
/**
* Marks that user confirmed or not to play video.
*
* @param {boolean} value - The value to set.
* @returns {{
* type: SET_CONFIRM_SHOW_VIDEO,
* }}
*/
export function setConfirmShowVideo(value: boolean) {
return {
type: SET_CONFIRM_SHOW_VIDEO,
value
};
}
/**
* Resets the status of the shared video.
@@ -89,17 +112,23 @@ export function stopSharedVideo() {
*/
export function playSharedVideo(videoUrl: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!isSharedVideoEnabled(getState())) {
return;
}
const conference = getCurrentConference(getState());
if (conference) {
const localParticipant = getLocalParticipant(getState());
dispatch(setSharedVideoStatus({
videoUrl,
status: 'start',
time: 0,
ownerId: localParticipant?.id
}));
// we will send the command and will create local video fake participant
// and start playing once we receive ourselves the command
sendShareVideoCommand({
conference,
id: videoUrl,
localParticipantId: localParticipant?.id,
status: PLAYBACK_START,
time: 0
});
}
};
}
@@ -115,10 +144,60 @@ export function toggleSharedVideo() {
const state = getState();
const { status = '' } = state['features/shared-video'];
if ([ 'playing', 'start', 'pause' ].includes(status)) {
if ([ PLAYBACK_STATUSES.PLAYING, PLAYBACK_START, PLAYBACK_STATUSES.PAUSED ].includes(status)) {
dispatch(stopSharedVideo());
} else {
dispatch(showSharedVideoDialog((id: string) => dispatch(playSharedVideo(id))));
}
};
}
/**
* Sets the allowed URL domains of the shared video.
*
* @param {Array<string>} allowedUrlDomains - The new whitelist to be set.
* @returns {{
* type: SET_ALLOWED_URL_DOMAINS,
* allowedUrlDomains: Array<string>
* }}
*/
export function setAllowedUrlDomians(allowedUrlDomains: Array<string>) {
return {
type: SET_ALLOWED_URL_DOMAINS,
allowedUrlDomains
};
}
/**
* Shows a confirmation dialog whether to play the external video link.
*
* @param {string} actor - The actor's name.
* @param {Function} onSubmit - The function to execute when confirmed.
*
* @returns {Function}
*/
export function showConfirmPlayingDialog(actor: String, onSubmit: Function) {
return (dispatch: IStore['dispatch']) => {
// shows only one dialog at a time
dispatch(setConfirmShowVideo(false));
dispatch(openDialog(ShareVideoConfirmDialog, {
actorName: actor,
onSubmit: () => {
dispatch(setConfirmShowVideo(true));
onSubmit();
}
}));
};
}
/**
* Hides the video play confirmation dialog.
*
* @returns {Function}
*/
export function hideConfirmPlayingDialog() {
return (dispatch: IStore['dispatch']) => {
dispatch(hideDialog(ShareVideoConfirmDialog));
};
}

View File

@@ -10,6 +10,11 @@ import { extractYoutubeIdOrURL } from '../functions';
*/
export interface IProps extends WithTranslation {
/**
* The allowed URL domains for shared video.
*/
_allowedUrlDomains: Array<string>;
/**
* Invoked to update the shared video link.
*/

View File

@@ -1,2 +1,4 @@
// @ts-ignore
export { default as SharedVideoDialog } from './native/SharedVideoDialog';
export { default as SharedVideoButton } from './native/SharedVideoButton';
export { default as ShareVideoConfirmDialog } from './native/ShareVideoConfirmDialog';

View File

@@ -1 +1,3 @@
export { default as SharedVideoDialog } from './web/SharedVideoDialog';
export { default as SharedVideoButton } from './web/SharedVideoButton';
export { default as ShareVideoConfirmDialog } from './web/ShareVideoConfirmDialog';

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { DialogProps } from '../../../base/dialog/constants';
interface IProps extends DialogProps {
/**
* The name of the remote participant that shared the video.
*/
actorName: string;
/**
* The function to execute when confirmed.
*/
onSubmit: () => void;
}
/**
* Dialog to confirm playing a video shared from a remote participant.
*
* @returns {JSX.Element}
*/
export default function ShareVideoConfirmDialog({ actorName, onSubmit }: IProps): JSX.Element {
const { t } = useTranslation();
return (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.Ok'
descriptionKey = 'dialog.shareVideoConfirmPlay'
onSubmit = { onSubmit }
title = { t('dialog.shareVideoConfirmPlayTitle', {
name: actorName
}) } />
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import InputDialog from '../../../base/dialog/components/native/InputDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractSharedVideoDialog, { IProps } from '../AbstractSharedVideoDialog';
@@ -67,4 +68,19 @@ class SharedVideoDialog extends AbstractSharedVideoDialog<IState> {
}
}
export default translate(connect()(SharedVideoDialog));
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { allowedUrlDomains } = state['features/shared-video'];
return {
_allowedUrlDomains: allowedUrlDomains
};
}
export default translate(connect(mapStateToProps)(SharedVideoDialog));

View File

@@ -130,7 +130,7 @@ class YoutubeVideoManager extends AbstractVideoManager<IState> {
});
}
if (event === 'playing') {
if (event === PLAYBACK_STATUSES.PLAYING) {
this.setState({
paused: false
}, () => {

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { DialogProps } from '../../../base/dialog/constants';
import Dialog from '../../../base/ui/components/web/Dialog';
interface IProps extends DialogProps {
/**
* The name of the remote participant that shared the video.
*/
actorName: string;
/**
* The function to execute when confirmed.
*/
onSubmit: () => void;
}
/**
* Dialog to confirm playing a video shared from a remote participant.
*
* @returns {JSX.Element}
*/
export default function ShareVideoConfirmDialog({ actorName, onSubmit }: IProps): JSX.Element {
const { t } = useTranslation();
return (
<Dialog
onSubmit = { onSubmit }
title = { t('dialog.shareVideoConfirmPlayTitle', {
name: actorName
}) }>
<div>
{ t('dialog.shareVideoConfirmPlay') }
</div>
</Dialog>
);
}

View File

@@ -7,6 +7,7 @@ import { IReduxState } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import { getVerticalViewMaxWidth } from '../../../filmstrip/functions.web';
import { getToolboxHeight } from '../../../toolbox/functions.web';
import { isSharedVideoEnabled } from '../../functions';
import VideoManager from './VideoManager';
import YoutubeVideoManager from './YoutubeVideoManager';
@@ -33,6 +34,11 @@ interface IProps {
*/
filmstripWidth: number;
/**
* Whether the shared video is enabled or not.
*/
isEnabled: boolean;
/**
* Is the video shared by the local user.
*/
@@ -118,7 +124,12 @@ class SharedVideo extends Component<IProps> {
* @returns {React$Element}
*/
render() {
const { isOwner, isResizing } = this.props;
const { isEnabled, isOwner, isResizing } = this.props;
if (!isEnabled) {
return null;
}
const className = !isResizing && isOwner ? '' : 'disable-pointer';
return (
@@ -152,6 +163,7 @@ function _mapStateToProps(state: IReduxState) {
clientWidth,
filmstripVisible: visible,
filmstripWidth: getVerticalViewMaxWidth(state),
isEnabled: isSharedVideoEnabled(state),
isOwner: ownerId === localParticipant?.id,
isResizing,
videoUrl

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { hideDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
@@ -99,4 +100,19 @@ class SharedVideoDialog extends AbstractSharedVideoDialog<any> {
}
}
export default translate(connect()(SharedVideoDialog));
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { allowedUrlDomains } = state['features/shared-video'];
return {
_allowedUrlDomains: allowedUrlDomains
};
}
export default translate(connect(mapStateToProps)(SharedVideoDialog));

View File

@@ -28,3 +28,23 @@ export const PLAYBACK_STATUSES = {
PAUSED: 'pause',
STOPPED: 'stop'
};
/**
* Playback start state.
*/
export const PLAYBACK_START = 'start';
/**
* The domain for youtube URLs.
*/
export const YOUTUBE_URL_DOMAIN = 'youtube.com';
/**
* The constant to allow URL domains.
*/
export const ALLOW_ALL_URL_DOMAINS = '*';
/**
* The default white listed domains for shared video.
*/
export const DEFAULT_ALLOWED_URL_DOMAINS = [ YOUTUBE_URL_DOMAIN ];

View File

@@ -1,7 +1,17 @@
import { IStateful } from '../base/app/types';
import { IJitsiConference } from '../base/conference/reducer';
import { getFakeParticipants } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { VIDEO_PLAYER_PARTICIPANT_NAME, YOUTUBE_PLAYER_PARTICIPANT_NAME } from './constants';
import {
ALLOW_ALL_URL_DOMAINS,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_VIDEO,
VIDEO_PLAYER_PARTICIPANT_NAME,
YOUTUBE_PLAYER_PARTICIPANT_NAME,
YOUTUBE_URL_DOMAIN
} from './constants';
/**
* Validates the entered video url.
@@ -29,7 +39,7 @@ function getYoutubeId(url: string) {
* @returns {boolean}
*/
export function isSharingStatus(status: string) {
return [ 'playing', 'pause', 'start' ].includes(status);
return [ PLAYBACK_STATUSES.PLAYING, PLAYBACK_STATUSES.PAUSED, PLAYBACK_START ].includes(status);
}
@@ -87,3 +97,80 @@ export function extractYoutubeIdOrURL(input: string) {
return trimmedLink;
}
/**
* Returns true if shared video functionality is enabled and false otherwise.
*
* @param {IStateful} stateful - - The redux store or {@code getState} function.
* @returns {boolean}
*/
export function isSharedVideoEnabled(stateful: IStateful) {
const state = toState(stateful);
const { disableThirdPartyRequests = false } = state['features/base/config'];
return !disableThirdPartyRequests;
}
/**
* Checks if you youtube URLs should be allowed for shared videos.
*
* @param {Array<string>} allowedUrlDomains - The allowed URL domains for shared video.
* @returns {boolean}
*/
export function areYoutubeURLsAllowedForSharedVideo(allowedUrlDomains?: Array<string>) {
return Boolean(allowedUrlDomains?.includes(YOUTUBE_URL_DOMAIN));
}
/**
* Returns true if the passed url is allowed to be used for shared video or not.
*
* @param {string} url - The URL.
* @param {Array<string>} allowedUrlDomains - The allowed url domains.
* @param {boolean} considerNonURLsAllowedForYoututbe - If true, the invalid URLs will be considered youtube IDs
* and if youtube is allowed the function will return true.
* @returns {boolean}
*/
export function isURLAllowedForSharedVideo(url: string,
allowedUrlDomains: Array<string> = [], considerNonURLsAllowedForYoututbe = false) {
if (!url) {
return false;
}
try {
const urlObject = new URL(url);
if ([ 'http:', 'https:' ].includes(urlObject?.protocol?.toLowerCase())) {
return allowedUrlDomains.includes(ALLOW_ALL_URL_DOMAINS) || allowedUrlDomains.includes(urlObject?.hostname);
}
} catch (_e) { // it should be YouTube id.
return considerNonURLsAllowedForYoututbe && allowedUrlDomains.includes(YOUTUBE_URL_DOMAIN);
}
return false;
}
/**
* Sends SHARED_VIDEO command.
*
* @param {string} id - The id of the video.
* @param {string} status - The status of the shared video.
* @param {JitsiConference} conference - The current conference.
* @param {string} localParticipantId - The id of the local participant.
* @param {string} time - The seek position of the video.
* @returns {void}
*/
export function sendShareVideoCommand({ id, status, conference, localParticipantId = '', time, muted, volume }: {
conference?: IJitsiConference; id: string; localParticipantId?: string; muted?: boolean;
status: string; time: number; volume?: number;
}) {
conference?.sendCommandOnce(SHARED_VIDEO, {
value: id,
attributes: {
from: localParticipantId,
muted,
state: status,
time,
volume
}
});
}

View File

@@ -0,0 +1,24 @@
import { useSelector } from 'react-redux';
import { SharedVideoButton } from './components';
import { isSharedVideoEnabled } from './functions';
const shareVideo = {
key: 'sharedvideo',
Content: SharedVideoButton,
group: 3
};
/**
* A hook that returns the shared video button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useSharedVideoButton() {
const sharedVideoEnabled = useSelector(isSharedVideoEnabled);
if (sharedVideoEnabled) {
return shareVideo;
}
}

View File

@@ -4,20 +4,33 @@ import { IStore } from '../app/types';
import { CONFERENCE_JOIN_IN_PROGRESS, CONFERENCE_LEFT } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { IJitsiConference } from '../base/conference/reducer';
import { SET_CONFIG } from '../base/config/actionTypes';
import { MEDIA_TYPE } from '../base/media/constants';
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import { participantJoined, participantLeft, pinParticipant } from '../base/participants/actions';
import { getLocalParticipant, getParticipantById } from '../base/participants/functions';
import { getLocalParticipant, getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
import { FakeParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SET_DYNAMIC_BRANDING_DATA } from '../dynamic-branding/actionTypes';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { RESET_SHARED_VIDEO_STATUS, SET_SHARED_VIDEO_STATUS } from './actionTypes';
import {
hideConfirmPlayingDialog,
resetSharedVideoStatus,
setSharedVideoStatus
setAllowedUrlDomians,
setSharedVideoStatus,
showConfirmPlayingDialog
} from './actions.any';
import { PLAYBACK_STATUSES, SHARED_VIDEO, VIDEO_PLAYER_PARTICIPANT_NAME } from './constants';
import { isSharingStatus } from './functions';
import {
DEFAULT_ALLOWED_URL_DOMAINS,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_VIDEO,
VIDEO_PLAYER_PARTICIPANT_NAME
} from './constants';
import { isSharedVideoEnabled, isSharingStatus, isURLAllowedForSharedVideo, sendShareVideoCommand } from './functions';
import logger from './logger';
@@ -30,25 +43,61 @@ import logger from './logger';
*/
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
const state = getState();
if (!isSharedVideoEnabled(getState())) {
return next(action);
}
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
const { conference } = action;
const localParticipantId = getLocalParticipant(state)?.id;
const localParticipantId = getLocalParticipant(getState())?.id;
conference.addCommandListener(SHARED_VIDEO,
({ value, attributes }: { attributes: {
from: string; muted: string; state: string; time: string; }; value: string; }) => {
const state = getState();
const { from } = attributes;
const sharedVideoStatus = attributes.state;
if (isSharingStatus(sharedVideoStatus)) {
handleSharingVideoStatus(store, value, attributes, conference);
} else if (sharedVideoStatus === 'stop') {
// confirmShowVideo is undefined the first time we receive
// when confirmShowVideo is false we ignore everything except stop that resets it
if (getState()['features/shared-video'].confirmShowVideo === false) {
return;
}
if (isURLAllowedForSharedVideo(value, getState()['features/shared-video'].allowedUrlDomains, true)
|| localParticipantId === from
|| getState()['features/shared-video'].confirmShowVideo) { // if confirmed skip asking again
handleSharingVideoStatus(store, value, attributes, conference);
} else {
dispatch(showConfirmPlayingDialog(getParticipantDisplayName(getState(), from), () => {
handleSharingVideoStatus(store, value, attributes, conference);
return true; // on mobile this is used to close the dialog
}));
}
return;
}
if (sharedVideoStatus === 'stop') {
const videoParticipant = getParticipantById(state, value);
if (getState()['features/shared-video'].confirmShowVideo === false) {
dispatch(showWarningNotification({
titleKey: 'dialog.shareVideoLinkStopped',
titleArguments: {
name: getParticipantDisplayName(getState(), from)
}
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
}
dispatch(hideConfirmPlayingDialog());
dispatch(participantLeft(value, conference, {
fakeParticipant: videoParticipant?.fakeParticipant
}));
@@ -62,9 +111,11 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case CONFERENCE_LEFT:
dispatch(setAllowedUrlDomians(DEFAULT_ALLOWED_URL_DOMAINS));
dispatch(resetSharedVideoStatus());
break;
case PARTICIPANT_LEFT: {
const state = getState();
const conference = getCurrentConference(state);
const { ownerId: stateOwnerId, videoUrl: statevideoUrl } = state['features/shared-video'];
@@ -76,7 +127,23 @@ MiddlewareRegistry.register(store => next => action => {
}
break;
}
case SET_CONFIG:
case SET_DYNAMIC_BRANDING_DATA: {
const result = next(action);
const state = getState();
const { sharedVideoAllowedURLDomains: allowedURLDomainsFromConfig = [] } = state['features/base/config'];
const { sharedVideoAllowedURLDomains: allowedURLDomainsFromBranding = [] } = state['features/dynamic-branding'];
dispatch(setAllowedUrlDomians([
...DEFAULT_ALLOWED_URL_DOMAINS,
...allowedURLDomainsFromBranding,
...allowedURLDomainsFromConfig
]));
return result;
}
case SET_SHARED_VIDEO_STATUS: {
const state = getState();
const conference = getCurrentConference(state);
const localParticipantId = getLocalParticipant(state)?.id;
const { videoUrl, status, ownerId, time, muted, volume } = action;
@@ -88,6 +155,14 @@ MiddlewareRegistry.register(store => next => action => {
APP.API.notifyAudioOrVideoSharingToggled(MEDIA_TYPE.VIDEO, status, ownerId);
}
// when setting status we need to send the command for that, but not do it for the start command
// as we are sending the command in playSharedVideo and setting the start status once
// we receive the response, this way we will start the video at the same time when remote participants
// start it, on receiving the command
if (status === 'start') {
break;
}
if (localParticipantId === ownerId) {
sendShareVideoCommand({
conference,
@@ -102,6 +177,7 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case RESET_SHARED_VIDEO_STATUS: {
const state = getState();
const localParticipantId = getLocalParticipant(state)?.id;
const { ownerId: stateOwnerId, videoUrl: statevideoUrl } = state['features/shared-video'];
@@ -152,8 +228,19 @@ function handleSharingVideoStatus(store: IStore, videoUrl: string,
const { dispatch, getState } = store;
const localParticipantId = getLocalParticipant(getState())?.id;
const oldStatus = getState()['features/shared-video']?.status ?? '';
const oldVideoUrl = getState()['features/shared-video'].videoUrl;
if (state === 'start' || ![ 'playing', 'pause', 'start' ].includes(oldStatus)) {
if (oldVideoUrl && oldVideoUrl !== videoUrl) {
logger.warn(
`User with id: ${from} sent videoUrl: ${videoUrl} while we are playing: ${oldVideoUrl}`);
return;
}
// If the video was not started (no participant) we want to create the participant
// this can be triggered by start, but also by paused or playing
// commands (joining late) and getting the current state
if (state === PLAYBACK_START || !isSharingStatus(oldStatus)) {
const youtubeId = videoUrl.match(/http/) ? false : videoUrl;
const avatarURL = youtubeId ? `https://img.youtube.com/vi/${youtubeId}/0.jpg` : '';
@@ -166,6 +253,15 @@ function handleSharingVideoStatus(store: IStore, videoUrl: string,
}));
dispatch(pinParticipant(videoUrl));
if (localParticipantId === from) {
dispatch(setSharedVideoStatus({
videoUrl,
status: state,
time: Number(time),
ownerId: localParticipantId
}));
}
}
if (localParticipantId !== from) {
@@ -178,31 +274,3 @@ function handleSharingVideoStatus(store: IStore, videoUrl: string,
}));
}
}
/* eslint-disable max-params */
/**
* Sends SHARED_VIDEO command.
*
* @param {string} id - The id of the video.
* @param {string} status - The status of the shared video.
* @param {JitsiConference} conference - The current conference.
* @param {string} localParticipantId - The id of the local participant.
* @param {string} time - The seek position of the video.
* @returns {void}
*/
function sendShareVideoCommand({ id, status, conference, localParticipantId = '', time, muted, volume }: {
conference?: IJitsiConference; id: string; localParticipantId?: string; muted: boolean;
status: string; time: number; volume: number;
}) {
conference?.sendCommandOnce(SHARED_VIDEO, {
value: id,
attributes: {
from: localParticipantId,
muted,
state: status,
time,
volume
}
});
}

View File

@@ -3,7 +3,8 @@ import { getLocalParticipant } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { setDisableButton } from './actions.web';
import { SHARED_VIDEO } from './constants';
import { PLAYBACK_STATUSES, SHARED_VIDEO } from './constants';
import { isSharedVideoEnabled } from './functions';
import './middleware.any';
@@ -13,6 +14,10 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
if (!isSharedVideoEnabled(state)) {
break;
}
const { conference } = action;
conference.addCommandListener(SHARED_VIDEO, ({ attributes }: { attributes:
@@ -20,7 +25,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const { from } = attributes;
const status = attributes.state;
if (status === 'playing') {
if (status === PLAYBACK_STATUSES.PLAYING) {
if (localParticipantId !== from) {
dispatch(setDisableButton(true));
}

View File

@@ -1,10 +1,21 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { RESET_SHARED_VIDEO_STATUS, SET_DISABLE_BUTTON, SET_SHARED_VIDEO_STATUS } from './actionTypes';
import {
RESET_SHARED_VIDEO_STATUS,
SET_ALLOWED_URL_DOMAINS,
SET_CONFIRM_SHOW_VIDEO,
SET_DISABLE_BUTTON,
SET_SHARED_VIDEO_STATUS
} from './actionTypes';
import { DEFAULT_ALLOWED_URL_DOMAINS } from './constants';
const initialState = {};
const initialState = {
allowedUrlDomains: DEFAULT_ALLOWED_URL_DOMAINS
};
export interface ISharedVideoState {
allowedUrlDomains: Array<string>;
confirmShowVideo?: boolean;
disabled?: boolean;
muted?: boolean;
ownerId?: string;
@@ -23,7 +34,16 @@ ReducerRegistry.register<ISharedVideoState>('features/shared-video',
switch (action.type) {
case RESET_SHARED_VIDEO_STATUS:
return initialState;
return {
...initialState,
allowedUrlDomains: state.allowedUrlDomains
};
case SET_CONFIRM_SHOW_VIDEO: {
return {
...state,
confirmShowVideo: action.value
};
}
case SET_SHARED_VIDEO_STATUS:
return {
...state,
@@ -41,6 +61,13 @@ ReducerRegistry.register<ISharedVideoState>('features/shared-video',
disabled
};
case SET_ALLOWED_URL_DOMAINS: {
return {
...state,
allowedUrlDomains: action.allowedUrlDomains
};
}
default:
return state;
}

View File

@@ -19,6 +19,7 @@ import RecordButton from '../../../recording/components/Recording/native/RecordB
import SecurityDialogButton
from '../../../security/components/security-dialog/native/SecurityDialogButton';
import SharedVideoButton from '../../../shared-video/components/native/SharedVideoButton';
import { isSharedVideoEnabled } from '../../../shared-video/functions';
import SpeakerStatsButton from '../../../speaker-stats/components/native/SpeakerStatsButton';
import { isSpeakerStatsDisabled } from '../../../speaker-stats/functions';
import ClosedCaptionButton from '../../../subtitles/components/native/ClosedCaptionButton';
@@ -55,6 +56,11 @@ interface IProps {
*/
_isOpen: boolean;
/**
* Whether the shared video is enabled or not.
*/
_isSharedVideoEnabled: boolean;
/**
* Whether or not speaker stats is disable.
*/
@@ -121,6 +127,7 @@ class OverflowMenu extends PureComponent<IProps, IState> {
const {
_isBreakoutRoomsSupported,
_isSpeakerStatsDisabled,
_isSharedVideoEnabled,
_shouldDisplayReactionsButtons,
_width,
dispatch
@@ -168,7 +175,7 @@ class OverflowMenu extends PureComponent<IProps, IState> {
<WhiteboardButton { ...buttonProps } />
{/* @ts-ignore */}
<Divider style = { styles.divider as ViewStyle } />
<SharedVideoButton { ...buttonProps } />
{_isSharedVideoEnabled && <SharedVideoButton { ...buttonProps } />}
{!toolbarButtons.has('screensharing') && <ScreenSharingButton { ...buttonProps } />}
{!_isSpeakerStatsDisabled && <SpeakerStatsButton { ...buttonProps } />}
{!toolbarButtons.has('tileview') && <TileViewButton { ...buttonProps } />}
@@ -255,6 +262,7 @@ function _mapStateToProps(state: IReduxState) {
return {
_customToolbarButtons: customToolbarButtons,
_isBreakoutRoomsSupported: conference?.getBreakoutRooms()?.isSupported(),
_isSharedVideoEnabled: isSharedVideoEnabled(state),
_isSpeakerStatsDisabled: isSpeakerStatsDisabled(state),
_shouldDisplayReactionsButtons: shouldDisplayReactionsButtons(state),
_width: state['features/base/responsive-ui'].clientWidth

View File

@@ -45,7 +45,7 @@ import ShareAudioButton from '../screen-share/components/web/ShareAudioButton';
import { isScreenAudioSupported, isScreenVideoShared } from '../screen-share/functions';
import { useSecurityDialogButton } from '../security/hooks';
import SettingsButton from '../settings/components/web/SettingsButton';
import SharedVideoButton from '../shared-video/components/web/SharedVideoButton';
import { useSharedVideoButton } from '../shared-video/hooks';
import SpeakerStats from '../speaker-stats/components/web/SpeakerStats';
import { isSpeakerStatsDisabled } from '../speaker-stats/functions';
import { useSpeakerStatsButton } from '../speaker-stats/hooks.web';
@@ -142,12 +142,6 @@ const linkToSalesforce = {
group: 2
};
const shareVideo = {
key: 'sharedvideo',
Content: SharedVideoButton,
group: 3
};
const shareAudio = {
key: 'shareaudio',
Content: ShareAudioButton,
@@ -288,6 +282,7 @@ export function useToolboxButtons(
const liveStreaming = useLiveStreamingButton();
const linktosalesforce = useLinkToSalesforceButton();
const shareaudio = getShareAudioButton();
const shareVideo = useSharedVideoButton();
const whiteboard = useWhiteboardButton();
const etherpad = useEtherpadButton();
const virtualBackground = useVirtualBackgroundButton();

View File

@@ -15,6 +15,7 @@ import {
UPDATE_VISITORS_IN_QUEUE_COUNT,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import logger from './logger';
import { IPromotionRequest } from './types';
/**
@@ -95,6 +96,7 @@ export function demoteRequest(id: string) {
dispatch(disconnect(true))
.then(() => {
dispatch(setPreferVisitor(true));
logger.info('Dispatching connect to demote the local participant.');
return dispatch(connect());
});

View File

@@ -3,7 +3,6 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { IconArrowUp } from '../../../base/icons/svg';
import ToolboxButtonWithPopup from '../../../base/toolbox/components/web/ToolboxButtonWithPopup';
import Dialog from '../../../base/ui/components/web/Dialog';
import { RaiseHandButton } from '../../../reactions/components/web/RaiseHandButton';
@@ -55,8 +54,6 @@ export default function JoinMeetingDialog() {
<p className = { classes.raiseHandTooltip }>{t('visitors.joinMeeting.raiseHand')}</p>
<div className = { classes.raiseHandButton }>
<ToolboxButtonWithPopup
icon = { IconArrowUp }
iconDisabled = { false }
onPopoverClose = { noop }
onPopoverOpen = { noop }
popoverContent = { null }

View File

@@ -72,26 +72,26 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const { conference } = action;
if (getState()['features/visitors'].iAmVisitor) {
dispatch(openDialog(JoinMeetingDialog));
const { demoteActorDisplayName } = getState()['features/visitors'];
dispatch(setVisitorDemoteActor(undefined));
const notificationParams: INotificationProps = {
titleKey: 'visitors.notification.title',
descriptionKey: 'visitors.notification.description'
};
if (demoteActorDisplayName) {
notificationParams.descriptionKey = 'visitors.notification.demoteDescription';
notificationParams.descriptionArguments = {
actor: demoteActorDisplayName
const notificationParams: INotificationProps = {
titleKey: 'visitors.notification.title',
descriptionKey: 'visitors.notification.demoteDescription',
descriptionArguments: {
actor: demoteActorDisplayName
}
};
batch(() => {
dispatch(showNotification(notificationParams, NOTIFICATION_TIMEOUT_TYPE.STICKY));
dispatch(setVisitorDemoteActor(undefined));
});
} else {
dispatch(openDialog(JoinMeetingDialog));
}
// check for demote actor and update notification
dispatch(showNotification(notificationParams, NOTIFICATION_TIMEOUT_TYPE.STICKY));
} else {
dispatch(setVisitorsSupported(conference.isVisitorsSupported()));
conference.on(JitsiConferenceEvents.VISITORS_SUPPORTED_CHANGED, (value: boolean) => {
@@ -118,6 +118,8 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
dispatch(setVisitorDemoteActor(participantById.name));
}
logger.info('Dispatching connect on demote request visitor message for local participant.');
return dispatch(connect());
});
}
@@ -198,6 +200,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
setTimeout(() => {
logger.info('Dispatching joinConference on conference live event.');
dispatch(joinConference());
dispatch(setInVisitorsQueue(false));
}, Math.random() * delay);

View File

@@ -1,53 +0,0 @@
server {
listen 443 ssl;
server_name giphy-proxy.example.com;
ssl_certificate /etc/nginx/ssl/giphy-proxy.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/giphy-proxy.example.com.key;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.4.4 8.8.8.8 valid=300s;
resolver_timeout 10s;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
}
root /usr/share/nginx/html;
index index.html index.htm;
}
location /gifs/trending {
proxy_pass https://api.giphy.com/v1/gifs/trending;
}
location /gifs/search {
proxy_pass https://api.giphy.com/v1/gifs/search;
}
location ~ /gifs/id/(.*) {
proxy_pass https://i.giphy.com/media/$1/giphy.gif;
}
}