Compare commits

...

12 Commits

Author SHA1 Message Date
Calin-Teodor
417c38ab9e fix(filmstrip): keep AudioTracksContainer in the DOM while Filmstrip is hidden in reduced UI 2026-01-27 17:53:57 +02:00
Calinteodor
33a4245a1f fix(lobby): reset lobbyVisible state when we a potential conference is being left (#16881)
* Reset lobby state when we we dispatch conferenceWillLeave action.
2026-01-27 12:24:16 +02:00
Mihaela Dumitru
2eb07cb79f feat(participants): store user context data for external API events and functions 2026-01-27 10:42:02 +01:00
damencho
63e4c41d92 fix(tests): Fixes tests stack traces.
Before:
```
Error: element ("[data-testid="participant1-more-options-ba16a58a"]") still not existing after 1000ms
    at async ParticipantsPane.openParticipantContextMenu (/tmp/tmp.bMVHEDmYin/jitsi-meet/tests/pageobjects/ParticipantsPane.ts:202:9)
    at async BreakoutRooms.sendParticipantToBreakoutRoom (/tmp/tmp.bMVHEDmYin/jitsi-meet/tests/pageobjects/BreakoutRooms.ts:205:9)
```

After:
```
Error: element ("[data-testid="participant1-more-options-ecea6dd6"]") still not existing after 1000ms
    at async ParticipantsPane.openParticipantContextMenu (/tmp/tmp.j8VkoO9abR/jitsi-meet/tests/pageobjects/ParticipantsPane.ts:202:9)
    at async BreakoutRooms.sendParticipantToBreakoutRoom (/tmp/tmp.j8VkoO9abR/jitsi-meet/tests/pageobjects/BreakoutRooms.ts:205:9)
    at async Context.<anonymous> (/tmp/tmp.j8VkoO9abR/jitsi-meet/tests/specs/misc/breakoutRooms.spec.ts:349:9)
```
2026-01-26 12:30:32 -06:00
damencho
2c6ccd7d6b fix(transcriptions): Fixes stop transcriptions via api. 2026-01-23 13:39:48 -06:00
damencho
4ce27eeb1a chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2118.0.0+67fd2c84...v2124.0.0+80df84a1
2026-01-23 12:34:38 -06:00
damencho
11453dcc79 fix(transcriptions): Fixes stop transcriptions in some cases. 2026-01-23 10:18:23 -06:00
damencho
3375ee49bd fix(tests): Ignore clickable for password dialog.
It gives strange error that is not clickable, but it is seen on the screenshot and test passes without it.
2026-01-23 06:03:06 -06:00
damencho
e45df58cfb fix(tests): Fixes a problem with two notifications. 2026-01-23 06:03:06 -06:00
Calin-Teodor
7d628960d7 feat(breakout-rooms): button margin style fix 2026-01-22 10:52:48 -06:00
Calin-Teodor
cf13b8f0ba fix(responsive-ui): apply reducedUI only when both width and height are below threshold 2026-01-22 10:52:48 -06:00
Jaya Allamsetty
e106109090 fix(recording) Restores track mute state to original state after consent is provided.
Do not forcefully unmute audio and video if the user consents to being recorded and wants to stay unmuted but was muted before hitting join.
2026-01-22 10:11:43 -05:00
34 changed files with 246 additions and 72 deletions

View File

@@ -812,7 +812,7 @@ function initCommands() {
}
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(false, false, null));
APP.store.dispatch(setRequestingSubtitles(false, false, null, true));
}
if (mode === 'local') {

10
package-lock.json generated
View File

@@ -66,7 +66,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/v2118.0.0+67fd2c84/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2124.0.0+80df84a1/lib-jitsi-meet.tgz",
"lodash-es": "4.17.21",
"null-loader": "4.0.1",
"optional-require": "1.0.3",
@@ -18360,8 +18360,8 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2118.0.0+67fd2c84/lib-jitsi-meet.tgz",
"integrity": "sha512-V0fp3/ZJRjHZgRlpzfCcdIF4nZ+WWWkAxKuWVTMHXfcZj07aQUHTiwIukgRludtaj9RcyNcqCVC0PrGBeeUzTw==",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2124.0.0+80df84a1/lib-jitsi-meet.tgz",
"integrity": "sha512-wNfil8xxSjcrT3oNA5Lil0qETqR6W2XxpXNewYYjFiM2kSuWgH8fcVxgcQYzvGH+sOqEjdUEk1V81oNo/rB6tQ==",
"license": "Apache-2.0",
"dependencies": {
"@jitsi/js-utils": "^2.6.7",
@@ -39717,8 +39717,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2118.0.0+67fd2c84/lib-jitsi-meet.tgz",
"integrity": "sha512-V0fp3/ZJRjHZgRlpzfCcdIF4nZ+WWWkAxKuWVTMHXfcZj07aQUHTiwIukgRludtaj9RcyNcqCVC0PrGBeeUzTw==",
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2124.0.0+80df84a1/lib-jitsi-meet.tgz",
"integrity": "sha512-wNfil8xxSjcrT3oNA5Lil0qETqR6W2XxpXNewYYjFiM2kSuWgH8fcVxgcQYzvGH+sOqEjdUEk1V81oNo/rB6tQ==",
"requires": {
"@jitsi/js-utils": "^2.6.7",
"@jitsi/logger": "2.1.1",

View File

@@ -72,7 +72,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/v2118.0.0+67fd2c84/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2124.0.0+80df84a1/lib-jitsi-meet.tgz",
"lodash-es": "4.17.21",
"null-loader": "4.0.1",
"optional-require": "1.0.3",

View File

@@ -1,4 +1,4 @@
import { setRoom } from '../base/conference/actions';
import { setRoom } from '../base/conference/actions.native';
import { getConferenceState } from '../base/conference/functions';
import {
configWillLoad,
@@ -29,7 +29,7 @@ import {
} from '../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { clearNotifications } from '../notifications/actions';
import { isUnsafeRoomWarningEnabled } from '../prejoin/functions';
import { isUnsafeRoomWarningEnabled } from '../prejoin/functions.native';
import { maybeRedirectToTokenAuthUrl } from './actions.any';
import { addTrackStateToURL, getDefaultURL } from './functions.native';

View File

@@ -81,7 +81,6 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break;
}
case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
let descriptionKey;
let titleKey;
let uid = '';
const localParticipant = getLocalParticipant(getState);
@@ -111,8 +110,6 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
!raisedHand && dispatch(raiseHand(true));
dispatch(hideNotification(uid));
}) ],
descriptionKey,
sticky: true,
titleKey,
uid
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
@@ -271,7 +268,6 @@ StateListenerRegistry.register(
dispatch(showNotification({
titleKey: 'notify.hostAskedUnmute',
sticky: true,
customActionNameKey,
customActionHandler,
uid: ASKED_TO_UNMUTE_NOTIFICATION_ID

View File

@@ -93,10 +93,17 @@ export function commonUserJoinedHandling(
if (!user.isHidden()) {
const isReplacing = user?.isReplacing();
const isPromoted = conference?.getMetadataHandler().getMetadata()?.visitors?.promoted?.[id];
const userIdentity = user.getIdentity()?.user;
// Map identity from JWT context to userContext for external API
const userContext = userIdentity ? {
id: userIdentity.id,
name: userIdentity.name
} : undefined;
// the identity and avatar come from jwt and never change in the presence
dispatch(participantJoined({
avatarURL: user.getIdentity()?.user?.avatar,
avatarURL: userIdentity?.avatar,
botType: user.getBotType(),
conference,
id,
@@ -105,7 +112,8 @@ export function commonUserJoinedHandling(
role: user.getRole(),
isPromoted,
isReplacing,
sources: user.getSources()
sources: user.getSources(),
userContext
}));
}
}

View File

@@ -4,6 +4,7 @@ import { getCustomerDetails } from '../../jaas/actions.any';
import { getJaasJWT, isVpaasMeeting } from '../../jaas/functions';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
import { conferenceWillLeave } from '../conference/actions.native';
import { setJWT } from '../jwt/actions';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
@@ -58,5 +59,8 @@ export function connect(id?: string, password?: string) {
* @returns {Function}
*/
export function hangup(_requestFeedback = false) {
return (dispatch: IStore['dispatch']) => dispatch(appNavigate(undefined));
return (dispatch: IStore['dispatch']) => {
dispatch(conferenceWillLeave());
dispatch(appNavigate(undefined));
};
}

View File

@@ -606,13 +606,21 @@ function _e2eeUpdated({ getState, dispatch }: IStore, conference: IJitsiConferen
function _localParticipantJoined({ getState, dispatch }: IStore, next: Function, action: AnyAction) {
const result = next(action);
const settings = getState()['features/base/settings'];
const state = getState();
const settings = state['features/base/settings'];
const jwtUser = state['features/base/jwt']?.user;
const userContext = jwtUser ? {
id: jwtUser.id,
name: jwtUser.name
} : undefined;
dispatch(localParticipantJoined({
avatarURL: settings.avatarURL,
email: settings.email,
name: settings.displayName,
id: ''
id: '',
userContext
}));
return result;

View File

@@ -627,7 +627,8 @@ function _participantJoined({ participant }: { participant: IParticipant; }) {
pinned,
presence,
role,
sources
sources,
userContext
} = participant;
let { conference, id } = participant;
@@ -659,7 +660,8 @@ function _participantJoined({ participant }: { participant: IParticipant; }) {
pinned: pinned || false,
presence,
role: role || PARTICIPANT_ROLE.NONE,
sources
sources,
userContext
};
}

View File

@@ -41,6 +41,12 @@ export interface IParticipant {
role?: string;
sources?: Map<string, Map<string, ISourceInfo>>;
supportsRemoteControl?: boolean;
userContext?: IUserContext;
}
export interface IUserContext {
id?: string;
name?: string;
}
export interface ILocalParticipant extends IParticipant {

View File

@@ -113,7 +113,7 @@ export function setReducedUI(width: number, height: number) {
const threshold = navigator.product === 'ReactNative'
? REDUCED_UI_THRESHOLD
: WEB_REDUCED_UI_THRESHOLD;
const reducedUI = Math.min(width, height) < threshold;
const reducedUI = Math.max(width, height) < threshold;
if (reducedUI !== getState()['features/base/responsive-ui'].reducedUI) {
return dispatch({

View File

@@ -8,7 +8,7 @@ export default {
button: {
marginBottom: BaseTheme.spacing[4],
marginHorizontal: BaseTheme.spacing[2]
marginHorizontal: BaseTheme.spacing[3]
},
collapsibleList: {

View File

@@ -44,6 +44,16 @@ export const getMainRoom = (stateful: IStateful) => {
* @returns {IRoomsInfo} The rooms info.
*/
export const getRoomsInfo = (stateful: IStateful) => {
const state = toState(stateful);
const localParticipant = getLocalParticipant(stateful);
const jwtUser = state['features/base/jwt']?.user;
const localUserContext = jwtUser ? {
id: jwtUser.id,
name: jwtUser.name
} : {
id: localParticipant?.jwtId,
name: localParticipant?.name
};
const breakoutRooms = getBreakoutRooms(stateful);
const conference = getCurrentConference(stateful);
@@ -57,7 +67,6 @@ export const getRoomsInfo = (stateful: IStateful) => {
const conferenceParticipants = conference?.getParticipants()
.filter((participant: IJitsiParticipant) => !participant.isHidden());
const localParticipant = getLocalParticipant(stateful);
let localParticipantInfo;
if (localParticipant) {
@@ -65,7 +74,8 @@ export const getRoomsInfo = (stateful: IStateful) => {
role: localParticipant.role,
displayName: localParticipant.name,
avatarUrl: localParticipant.loadableAvatarUrl,
id: localParticipant.id
id: localParticipant.id,
userContext: localUserContext
};
}
@@ -86,7 +96,8 @@ export const getRoomsInfo = (stateful: IStateful) => {
role: participantItem.getRole(),
displayName: participantItem.getDisplayName(),
avatarUrl: storeParticipant?.loadableAvatarUrl,
id: participantItem.getId()
id: participantItem.getId(),
userContext: storeParticipant?.userContext
} as IRoomInfoParticipant;
}) ]
: [ localParticipantInfo ]
@@ -110,13 +121,18 @@ export const getRoomsInfo = (stateful: IStateful) => {
const storeParticipant = getParticipantById(stateful,
ids.length > 1 ? ids[1] : participantItem.jid);
// Check if this is the local participant
const isLocal = storeParticipant?.id === localParticipant?.id;
const userContext = isLocal ? localUserContext : (storeParticipant?.userContext || participantItem.userContext);
return {
jid: participantItem?.jid,
role: participantItem?.role,
displayName: participantItem?.displayName,
avatarUrl: storeParticipant?.loadableAvatarUrl,
id: storeParticipant ? storeParticipant.id
: participantLongId
: participantLongId,
userContext
} as IRoomInfoParticipant;
}) : []
} as IRoomInfo;

View File

@@ -44,19 +44,59 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (type) {
case UPDATE_BREAKOUT_ROOMS: {
// edit name if it was overwritten
// Enrich participants with userContext from Redux store
if (!action.updatedNames) {
const { overwrittenNameList } = getState()['features/base/participants'];
const state = getState();
const { overwrittenNameList, local: localParticipant } = state['features/base/participants'];
const jwtUser = state['features/base/jwt']?.user;
const localUserContext = jwtUser ? {
id: jwtUser.id,
name: jwtUser.name
} : {
id: localParticipant?.jwtId,
name: localParticipant?.name
};
if (Object.keys(overwrittenNameList).length > 0) {
const newRooms: IRooms = {};
// Get existing userContext cache
const existingCache = state['features/breakout-rooms'].userContextCache || {};
const newCache = { ...existingCache };
Object.entries(action.rooms as IRooms).forEach(([ key, r ]) => {
let participants = r?.participants || {};
let jid;
const newRooms: IRooms = {};
Object.entries(action.rooms as IRooms).forEach(([ key, r ]) => {
let participants = r?.participants || {};
// Add userContext to each participant
const enhancedParticipants: typeof participants = {};
for (const [ participantJid, participantData ] of Object.entries(participants)) {
const ids = participantJid.split('/');
const participantId = ids.length > 1 ? ids[1] : participantData.jid;
const storeParticipant = getParticipantById(state, participantId);
const isLocal = storeParticipant?.id === localParticipant?.id;
// Try to get userContext from: local, store, cache, or incoming data
const userContext = isLocal
? localUserContext
: (storeParticipant?.userContext || newCache[participantId] || participantData.userContext);
// Update cache if we have userContext
if (userContext && participantId) {
newCache[participantId] = userContext;
}
enhancedParticipants[participantJid] = {
...participantData,
userContext
};
}
participants = enhancedParticipants;
// Apply overwritten display names
if (Object.keys(overwrittenNameList).length > 0) {
for (const id of Object.keys(overwrittenNameList)) {
jid = Object.keys(participants).find(p => p.slice(p.indexOf('/') + 1) === id);
const jid = Object.keys(participants).find(p => p.slice(p.indexOf('/') + 1) === id);
if (jid) {
participants = {
@@ -68,15 +108,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
};
}
}
}
newRooms[key] = {
...r,
participants
};
});
newRooms[key] = {
...r,
participants
};
});
action.rooms = newRooms;
}
action.rooms = newRooms;
action.userContextCache = newCache;
}
// edit the chat history to match names for participants in breakout rooms

View File

@@ -10,12 +10,19 @@ import { IRooms } from './types';
const DEFAULT_STATE = {
rooms: {},
roomCounter: 0
roomCounter: 0,
userContextCache: {}
};
export interface IBreakoutRoomsState {
roomCounter: number;
rooms: IRooms;
userContextCache: {
[participantId: string]: {
id?: string;
name?: string;
};
};
}
/**
@@ -29,12 +36,13 @@ ReducerRegistry.register<IBreakoutRoomsState>(FEATURE_KEY, (state = DEFAULT_STAT
roomCounter: action.roomCounter
};
case UPDATE_BREAKOUT_ROOMS: {
const { roomCounter, rooms } = action;
const { roomCounter, rooms, userContextCache } = action;
return {
...state,
roomCounter,
rooms
rooms,
userContextCache: userContextCache || state.userContextCache
};
}
case _RESET_BREAKOUT_ROOMS: {

View File

@@ -8,6 +8,10 @@ export interface IRoom {
displayName: string;
jid: string;
role: string;
userContext?: {
id?: string;
name?: string;
};
};
};
}
@@ -33,4 +37,8 @@ export interface IRoomInfoParticipant {
id: string;
jid: string;
role: string;
userContext?: {
id?: string;
name?: string;
};
}

View File

@@ -257,6 +257,9 @@ class Conference extends AbstractConference<IProps, any> {
id = 'videospace'
onTouchStart = { this._onVideospaceTouchStart }>
<LargeVideo />
<StageFilmstrip />
<ScreenshareFilmstrip />
<MainFilmstrip />
</div>
<span
aria-level = { 1 }

View File

@@ -212,7 +212,7 @@ MiddlewareRegistry.register(store => next => action => {
const state = store.getState();
const { defaultRemoteDisplayName } = state['features/base/config'];
const { participant } = action;
const { fakeParticipant, id, local, name } = participant;
const { fakeParticipant, id, local, name, userContext } = participant;
// The version of external api outside of middleware did not emit
// the local participant being created.
@@ -225,7 +225,8 @@ MiddlewareRegistry.register(store => next => action => {
APP.API.notifyUserJoined(id, {
displayName: name,
formattedDisplayName: appendSuffix(
name || defaultRemoteDisplayName)
name || defaultRemoteDisplayName),
userContext
});
}

View File

@@ -338,6 +338,11 @@ export interface IProps extends WithTranslation {
*/
_maxTopPanelHeight: number;
/**
* Whethere reduced UI feature is enabled or not.
*/
_reducedUI: boolean;
/**
* The participants in the call.
*/
@@ -546,6 +551,7 @@ class Filmstrip extends PureComponent <IProps, IState> {
_filmstripDisabled,
_localScreenShareId,
_mainFilmstripVisible,
_reducedUI,
_resizableFilmstrip,
_topPanelFilmstrip,
_topPanelMaxHeight,
@@ -589,6 +595,13 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
}
// FIX: Until we move AudioTracksContainer to a more global place,
// we apply this css hot fix to hide Filmstrip but keep AudioTracksContainer in the DOM,
// so we don't have audio problems when reduced UI is enabled.
if (_reducedUI) {
filmstripStyle.display = 'none';
}
let toolbar: React.ReactNode = null;
if (!this.props._iAmRecorder && this.props._isFilmstripButtonEnabled
@@ -1120,6 +1133,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
const _currentLayout = getCurrentLayout(state);
const _isVerticalFilmstrip = _currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW
|| (filmstripType === FILMSTRIP_TYPE.MAIN && _currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW);
const { reducedUI } = state['features/base/responsive-ui'];
return {
_className: className,
@@ -1137,6 +1151,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
_mainFilmstripVisible: notDisabled,
_maxFilmstripWidth: videoSpaceWidth - MIN_STAGE_VIEW_WIDTH,
_maxTopPanelHeight: clientHeight - MIN_STAGE_VIEW_HEIGHT,
_reducedUI: reducedUI,
_remoteParticipantsLength: _remoteParticipants?.length ?? 0,
_topPanelHeight: topPanelHeight.current,
_topPanelMaxHeight: topPanelHeight.current || TOP_FILMSTRIP_HEIGHT,

View File

@@ -2,6 +2,7 @@ import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_LEAVE,
SET_PASSWORD
} from '../base/conference/actionTypes';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
@@ -69,11 +70,13 @@ ReducerRegistry.register<ILobbyState>('features/lobby', (state = DEFAULT_STATE,
}
case CONFERENCE_JOINED:
case CONFERENCE_LEFT:
case CONFERENCE_WILL_LEAVE:
return {
...state,
isDisplayNameRequiredError: false,
knocking: false,
passwordJoinFailed: false
lobbyVisible: false,
passwordJoinFailed: false,
};
case KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED:
return _knockingParticipantArrivedOrUpdated(action.participant, state);

View File

@@ -603,7 +603,7 @@ function _registerForNativeEvents(store: IStore) {
}
if (transcription) {
store.dispatch(setRequestingSubtitles(false, false, null));
store.dispatch(setRequestingSubtitles(false, false, null, true));
}
if (![ JitsiRecordingConstants.mode.FILE, JitsiRecordingConstants.mode.STREAM ].includes(mode)) {

View File

@@ -13,7 +13,6 @@ export interface INotificationProps {
hideErrorSupportLink?: boolean;
icon?: string;
maxLines?: number;
sticky?: boolean;
title?: string;
titleArguments?: {
[key: string]: string | number;

View File

@@ -37,16 +37,25 @@ export function grantRecordingConsent() {
/**
* Grants recording consent, unmutes audio/video, and closes the dialog.
* Restores the mute state that existed before the consent dialog was shown,
* respecting the user's intentional mute choices from prejoin or initial settings.
*
* @returns {Function}
*/
export function grantRecordingConsentAndUnmute() {
return (dispatch: IStore['dispatch']) => {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
// Read the prejoin mute state from the dialog props stored in Redux
const dialogProps = getState()['features/base/dialog'].componentProps as any;
const audioMuted = dialogProps?.audioWasMuted ?? false;
const videoMuted = dialogProps?.videoWasMuted ?? false;
batch(() => {
dispatch(setAudioUnmutePermissions(false, true));
dispatch(setVideoUnmutePermissions(false, true));
dispatch(setAudioMuted(false, true));
dispatch(setVideoMuted(false, VIDEO_MUTISM_AUTHORITY.USER, true));
// Restore to the mute state before consent was requested.
dispatch(setAudioMuted(audioMuted, false));
dispatch(setVideoMuted(videoMuted, VIDEO_MUTISM_AUTHORITY.USER, false));
dispatch(hideDialog());
});
};

View File

@@ -108,7 +108,8 @@ export default class AbstractStopRecordingDialog<P extends IProps>
}
// TODO: this should be an action in transcribing. -saghul
this.props.dispatch(setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage));
this.props.dispatch(
setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage, true));
this.props._conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false

View File

@@ -8,20 +8,21 @@ import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDial
import { setAudioMuted, setAudioUnmutePermissions, setVideoMuted, setVideoUnmutePermissions } from '../../../../base/media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../../../../base/media/constants';
import Link from '../../../../base/react/components/native/Link';
import { IRecordingConsentDialogProps } from '../../../reducer';
import styles from '../styles.native';
/**
* Component that renders the dialog for explicit consent for recordings.
*
* @param {IRecordingConsentDialogProps} props - The component props.
* @returns {JSX.Element}
*/
export default function RecordingConsentDialog() {
export default function RecordingConsentDialog({ audioWasMuted = false, videoWasMuted = false }: IRecordingConsentDialogProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { recordings } = useSelector((state: IReduxState) => state['features/base/config']);
const { consentLearnMoreLink } = recordings ?? {};
const consent = useCallback(() => {
dispatch(setAudioUnmutePermissions(false, true));
dispatch(setVideoUnmutePermissions(false, true));
@@ -32,11 +33,13 @@ export default function RecordingConsentDialog() {
const consentAndUnmute = useCallback(() => {
dispatch(setAudioUnmutePermissions(false, true));
dispatch(setVideoUnmutePermissions(false, true));
dispatch(setAudioMuted(false, true));
dispatch(setVideoMuted(false, VIDEO_MUTISM_AUTHORITY.USER, true));
// Restore to the mute state before consent was requested.
dispatch(setAudioMuted(audioWasMuted, false));
dispatch(setVideoMuted(videoWasMuted, VIDEO_MUTISM_AUTHORITY.USER, false));
return true;
}, []);
}, [ audioWasMuted, videoWasMuted ]);
return (
<ConfirmDialog

View File

@@ -10,6 +10,7 @@ import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../../
/**
* Component that renders the dialog for explicit consent for recordings.
* The prejoin mute state is read from Redux by the action creator.
*
* @returns {JSX.Element}
*/

View File

@@ -421,12 +421,21 @@ function _showExplicitConsentDialog(recorderSession: any, dispatch: IStore['disp
return;
}
// Capture the current mute state BEFORE forcing mute for consent
// This preserves the user's intentional mute choices from prejoin or initial settings
const state = getState();
const audioWasMuted = state['features/base/media'].audio.muted;
const videoWasMuted = state['features/base/media'].video.muted;
batch(() => {
dispatch(markConsentRequested(recorderSession.getID()));
dispatch(setAudioUnmutePermissions(true, true));
dispatch(setVideoUnmutePermissions(true, true));
dispatch(setAudioMuted(true));
dispatch(setVideoMuted(true));
dispatch(openDialog('RecordingConsentDialog', RecordingConsentDialog));
dispatch(openDialog('RecordingConsentDialog', RecordingConsentDialog, {
audioWasMuted,
videoWasMuted
}));
});
}

View File

@@ -42,6 +42,14 @@ export interface IRecordingState {
wasStartRecordingSuggested?: boolean;
}
/**
* Props for the RecordingConsentDialog component.
*/
export interface IRecordingConsentDialogProps {
audioWasMuted?: boolean;
videoWasMuted?: boolean;
}
/**
* The name of the Redux store this feature stores its state in.
*/

View File

@@ -393,7 +393,7 @@ function _requestingSubtitlesChange(
language.replace('translation-languages:', ''));
}
if (!enabled && backendRecordingOn
if (!enabled && (backendRecordingOn || forceBackendRecordingOn)
&& conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID]?.isTranscribingEnabled) {
conference?.getMetadataHandler()?.setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false

View File

@@ -2,6 +2,7 @@ import BasePageObject from './BasePageObject';
const ASK_TO_UNMUTE_NOTIFICATION_ID = 'notify.hostAskedUnmute';
const AV_MODERATION_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectTitle';
const AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID = 'notify.moderationInEffectVideoTitle';
const JOIN_MULTIPLE_TEST_ID = 'notify.connectedThreePlusMembers';
const JOIN_ONE_TEST_ID = 'notify.connectedOneMember';
const JOIN_TWO_TEST_ID = 'notify.connectedTwoMembers';
@@ -57,6 +58,13 @@ export default class Notifications extends BasePageObject {
return this.closeNotification(AV_MODERATION_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the video muted notification.
*/
async closeAVModerationVideoMutedNotification(skipNonExisting = false) {
return this.closeNotification(AV_MODERATION_VIDEO_MUTED_NOTIFICATION_ID, skipNonExisting);
}
/**
* Closes the ask to unmute notification.
*/
@@ -106,14 +114,22 @@ export default class Notifications extends BasePageObject {
* @private
*/
private async closeNotification(testId: string, skipNonExisting = false) {
const closeButton = this.participant.driver.$('[data-testid="${testId}"] #close-notification');
const closeButton = this.participant.driver.$(`[data-testid="${testId}"] #close-notification`);
if (skipNonExisting && !await closeButton.isExisting()) {
return Promise.resolve();
try {
if (skipNonExisting && !await closeButton.isExisting()) {
return Promise.resolve();
}
await closeButton.moveTo();
await closeButton.click();
} catch (e) {
console.error(`Error closing notification ${testId}`, e);
if (!skipNonExisting) {
throw e;
}
}
await closeButton.moveTo();
await closeButton.click();
}
/**

View File

@@ -40,7 +40,7 @@ export default class PasswordDialog extends BaseDialog {
const passwordInput = this.participant.driver.$(INPUT_KEY_XPATH);
await passwordInput.waitForExist();
await passwordInput.waitForClickable({ timeout: 2000 });
await passwordInput.waitForStable();
await passwordInput.click();
await passwordInput.clearValue();

View File

@@ -45,6 +45,8 @@ export async function unmuteAudioAndCheck(testee: Participant, observer: Partici
* @param observer
*/
export async function unmuteVideoAndCheck(testee: Participant, observer: Participant): Promise<void> {
await testee.getNotifications().closeAskToUnmuteNotification(true);
await testee.getNotifications().closeAVModerationVideoMutedNotification(true);
await testee.getToolbar().clickVideoUnmuteButton();
await testee.getParticipantsPane().assertVideoMuteIconIsDisplayed(testee, true);

View File

@@ -122,12 +122,12 @@ describe('Audio/video moderation', () => {
await moderatorParticipantsPane.assertVideoMuteIconIsDisplayed(moderator);
await nonModeratorParticipantsPane.assertVideoMuteIconIsDisplayed(nonModerator);
await moderatorParticipantsPane.allowVideo(nonModerator);
await moderatorParticipantsPane.askToUnmute(nonModerator, false);
await nonModerator.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(nonModerator, p1);
await moderatorParticipantsPane.allowVideo(nonModerator);
await nonModerator.getNotifications().waitForAskToUnmuteNotification();
await unmuteVideoAndCheck(nonModerator, p1);
await moderatorParticipantsPane.clickContextMenuButton();
@@ -190,6 +190,10 @@ describe('Audio/video moderation', () => {
// stop video and check
await p1.getFilmstrip().muteVideo(p2);
// close and open participants pane to make sure the context menu disappears
await p1.getParticipantsPane().close();
await p1.getParticipantsPane().open();
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
@@ -256,11 +260,12 @@ async function unmuteByModerator(
await moderator.getNotifications().waitForRaisedHandNotification();
// ask participant to unmute
await moderatorParticipantsPane.allowVideo(participant);
await moderatorParticipantsPane.askToUnmute(participant, false);
await participant.getNotifications().waitForAskToUnmuteNotification();
await unmuteAudioAndCheck(participant, moderator);
await moderatorParticipantsPane.allowVideo(participant);
await participant.getNotifications().waitForAskToUnmuteNotification();
await unmuteVideoAndCheck(participant, moderator);
if (stopModeration) {

View File

@@ -194,6 +194,8 @@ export const config: WebdriverIO.MultiremoteConfig = {
} ]
],
execArgv: [ '--stack-trace-limit=100' ],
// =====
// Hooks
// =====