Compare commits

...

8 Commits

Author SHA1 Message Date
Mihaela Dumitru
c457ed0d3c feat(external-api): fire participantMuted event for all mute state changes (#16893) 2026-02-03 10:59:28 +02:00
damencho
1efc5e40e1 fix(settings): Fixes rendering a tab when props change.
The Profile tab can display logged in state and auth id used for it. It does not re-render when this changes in the background.
2026-02-02 15:52:19 -06:00
Дамян Минков
dc84826d9c fix(muc_cleanup_backend_services): Stops timer before creating new one. (#16894)
* fix(muc_cleanup_backend_services): Stops timer before creating new one.

* squash: Adds some logs and skip if already being destroyed.
2026-02-02 10:09:26 -06:00
Horatiu Muresan
b4a64ebc44 fix(recording-button) Fix recording button tooltip (#16891) 2026-01-30 13:28:39 +02:00
Hristo Terezov
dbb3ccc274 feat(CustomPanel): Implement an customizable panel.
The panel will appear on the right side after the participant pane panel. Currently the panel is disabled by default and the components that  are rendered in the panel are empty (null).  The panel is easily customizable by adding some content in the CustomPanel component.
2026-01-29 14:59:06 -06:00
bgrozev
9a6ed65cb1 test: Retry some tests (#16888)
* chore: Update wdio to 9.23.2.

* test: Add a retry test property.

* test: Retry some of the flaky tests.
2026-01-29 13:58:57 -06:00
bgrozev
21ea67b29c fix: Only parse transcript for transcription-result messages. (#16885) 2026-01-28 16:23:12 -06:00
bgrozev
ab4be2366f test: Add test for jaas "async" transcriptions. (#16793)
* test: Add test for jaas "async" transcriptions.

* fix: Do not expect name in async transcription events.
2026-01-27 18:56:16 -06:00
39 changed files with 1754 additions and 354 deletions

View File

@@ -227,6 +227,9 @@
"video_ssrc": "Video SSRC:",
"yes": "yes"
},
"customPanel": {
"close": "Close"
},
"dateUtils": {
"earlier": "Earlier",
"today": "Today",
@@ -1315,6 +1318,7 @@
"chat": "Open / Close chat",
"clap": "Clap",
"closeChat": "Close chat",
"closeCustomPanel": "Close",
"closeMoreActions": "Close more actions menu",
"closeParticipantsPane": "Close participants pane",
"closedCaptions": "Closed captions",
@@ -1420,9 +1424,11 @@
"chat": "Open / Close chat",
"clap": "Clap",
"closeChat": "Close chat",
"closeCustomPanel": "Close",
"closeParticipantsPane": "Close participants pane",
"closeReactionsMenu": "Close reactions menu",
"closedCaptions": "Closed captions",
"copilot": "Copilot",
"disableNoiseSuppression": "Disable extra noise suppression",
"disableReactionSounds": "You can disable reaction sounds for this meeting",
"documentClose": "Close shared document",

View File

@@ -240,13 +240,27 @@ function initCommands() {
APP.store.dispatch(muteAllParticipants(exclude, muteMediaType));
},
'mute-remote-participant': (participantId, mediaType) => {
if (!isLocalParticipantModerator(APP.store.getState())) {
logger.error('Missing moderator rights to mute remote participant');
const state = APP.store.getState();
const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO;
const localParticipant = getLocalParticipant(state);
// Check if targeting the local participant
if (participantId === localParticipant?.id) {
if (muteMediaType === MEDIA_TYPE.AUDIO) {
APP.conference.toggleAudioMuted(false);
} else if (muteMediaType === MEDIA_TYPE.VIDEO) {
APP.conference.toggleVideoMuted(false, true);
}
return;
}
const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO;
if (!isLocalParticipantModerator(state)) {
logger.error('Missing moderator rights to mute remote participant');
return;
}
APP.store.dispatch(muteRemote(participantId, muteMediaType));
},
@@ -1417,17 +1431,15 @@ class API {
*
* @param {string} participantId - The ID of the participant.
* @param {boolean} isMuted - True if muted, false if unmuted.
* @param {string} mediaType - Media type that was muted ('audio', 'video', or 'desktop').
* @param {boolean} isSelfMuted - True if participant muted themselves, false if muted by moderator.
* @param {string} mediaType - Media type that was muted ('audio' or 'video').
* @returns {void}
*/
notifyParticipantMuted(participantId, isMuted, mediaType, isSelfMuted = true) {
notifyParticipantMuted(participantId, isMuted, mediaType) {
this._sendEvent({
name: 'participant-muted',
id: participantId,
isMuted,
mediaType,
isSelfMuted
mediaType
});
}

1301
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -164,12 +164,12 @@
"@types/w3c-image-capture": "1.0.6",
"@types/w3c-web-hid": "1.0.3",
"@types/zxcvbn": "4.4.1",
"@wdio/allure-reporter": "9.22.0",
"@wdio/cli": "9.22.0",
"@wdio/globals": "9.17.0",
"@wdio/junit-reporter": "9.21.0",
"@wdio/local-runner": "9.22.0",
"@wdio/mocha-framework": "9.22.0",
"@wdio/allure-reporter": "9.23.2",
"@wdio/cli": "9.23.2",
"@wdio/globals": "9.23.0",
"@wdio/junit-reporter": "9.23.2",
"@wdio/local-runner": "9.23.2",
"@wdio/mocha-framework": "9.23.2",
"babel-loader": "9.1.0",
"babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0",

View File

@@ -26,5 +26,6 @@ import '../face-landmarks/middleware';
import '../gifs/middleware';
import '../whiteboard/middleware.web';
import '../file-sharing/middleware.web';
import '../custom-panel/middleware.web';
import './middlewares.any';

View File

@@ -1,5 +1,6 @@
import '../base/devices/reducer';
import '../base/premeeting/reducer';
import '../custom-panel/reducer';
import '../base/tooltip/reducer';
import '../e2ee/reducer';
import '../face-landmarks/reducer';

View File

@@ -31,6 +31,7 @@ import { IUserInteractionState } from '../base/user-interaction/reducer';
import { IBreakoutRoomsState } from '../breakout-rooms/reducer';
import { ICalendarSyncState } from '../calendar-sync/reducer';
import { IChatState } from '../chat/reducer';
import { ICustomPanelState } from '../custom-panel/reducer';
import { IDeepLinkingState } from '../deep-linking/reducer';
import { IDropboxState } from '../dropbox/reducer';
import { IDynamicBrandingState } from '../dynamic-branding/reducer';
@@ -121,6 +122,7 @@ export interface IReduxState {
'features/calendar-sync': ICalendarSyncState;
'features/call-integration': ICallIntegrationState;
'features/chat': IChatState;
'features/custom-panel': ICustomPanelState;
'features/deep-linking': IDeepLinkingState;
'features/dropbox': IDropboxState;
'features/dynamic-branding': IDynamicBrandingState;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles-icon lucide-sparkles"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></svg>

After

Width:  |  Height:  |  Size: 582 B

View File

@@ -1,3 +1,4 @@
import { default as IconAI } from './AI.svg';
import { default as IconRecordAccount } from './account-record.svg';
import { default as IconAddUser } from './add-user.svg';
import { default as IconArrowBack } from './arrow-back.svg';
@@ -112,6 +113,7 @@ import { default as IconYahoo } from './yahoo.svg';
*/
export const DEFAULT_ICON: Record<string, any> = {
IconAddUser,
IconAI,
IconArrowBack,
IconArrowDown,
IconArrowDownLarge,

View File

@@ -4,6 +4,7 @@ import { DEFAULT_ICON } from './constants';
const {
IconAddUser,
IconAI,
IconArrowBack,
IconArrowDown,
IconArrowDownLarge,
@@ -123,6 +124,7 @@ const {
export {
IconAddUser,
IconAI,
IconArrowBack,
IconArrowDown,
IconArrowDownLarge,

View File

@@ -1,4 +1,5 @@
import { IReduxState, IStore } from '../../app/types';
import { getLocalParticipant } from '../participants/functions';
import StateListenerRegistry from '../redux/StateListenerRegistry';
/**
@@ -13,6 +14,13 @@ StateListenerRegistry.register(
if (muted !== previousMuted) {
APP.API.notifyAudioMutedStatusChanged(muted);
// Also fire the participantMuted event for consistency
const localParticipant = getLocalParticipant(store.getState());
if (localParticipant) {
APP.API.notifyParticipantMuted(localParticipant.id, muted, 'audio');
}
}
}
);

View File

@@ -2,6 +2,7 @@ import { batch } from 'react-redux';
import { IStore } from '../../app/types';
import { CHAT_SIZE } from '../../chat/constants';
import { getCustomPanelWidth } from '../../custom-panel/functions';
import { getParticipantsPaneWidth } from '../../participants-pane/functions';
import {
@@ -51,6 +52,7 @@ export function clientResized(clientWidth: number, clientHeight: number) {
}
availableWidth -= getParticipantsPaneWidth(state);
availableWidth -= getCustomPanelWidth(state);
reducedUIEnabled && dispatch(setReducedUI(availableWidth, clientHeight));
}

View File

@@ -51,7 +51,7 @@ import './subscriber.web';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case TRACK_ADDED: {
const { local } = action.track;
const { local, jitsiTrack } = action.track;
// The devices list needs to be refreshed when no initial video permissions
// were granted and a local video track is added by umuting the video.
@@ -65,6 +65,16 @@ MiddlewareRegistry.register(store => next => action => {
if (participantId) {
logTracksForParticipant(store.getState()['features/base/tracks'], participantId, 'Track added');
// Fire participantMuted event for initial state of remote tracks
if (typeof action.track?.muted !== 'undefined' && jitsiTrack) {
const isVideoTrack = jitsiTrack.getType() !== MEDIA_TYPE.AUDIO;
const mediaType = isVideoTrack
? (jitsiTrack.getVideoType() === VIDEO_TYPE.DESKTOP ? 'desktop' : 'video')
: 'audio';
APP.API.notifyParticipantMuted(participantId, action.track.muted, mediaType);
}
}
return result;
@@ -119,6 +129,16 @@ MiddlewareRegistry.register(store => next => action => {
// TODO Remove the following calls to APP.UI once components interested
// in track mute changes are moved into React and/or redux.
const { jitsiTrack } = action.track;
const participantID = jitsiTrack.getParticipantId();
const isVideoTrack = jitsiTrack.type !== MEDIA_TYPE.AUDIO;
const local = jitsiTrack.isLocal();
// Get old muted state BEFORE updating
const tracks = store.getState()['features/base/tracks'];
const oldTrack = tracks.find((t: ITrack) => t.jitsiTrack === jitsiTrack);
const oldMutedState = oldTrack?.muted;
const result = next(action);
const state = store.getState();
@@ -126,11 +146,6 @@ MiddlewareRegistry.register(store => next => action => {
return result;
}
const { jitsiTrack } = action.track;
const participantID = jitsiTrack.getParticipantId();
const isVideoTrack = jitsiTrack.type !== MEDIA_TYPE.AUDIO;
const local = jitsiTrack.isLocal();
if (isVideoTrack) {
if (local && !(jitsiTrack.getVideoType() === VIDEO_TYPE.DESKTOP)) {
APP.conference.setVideoMuteStatus();
@@ -144,12 +159,14 @@ MiddlewareRegistry.register(store => next => action => {
if (typeof action.track?.muted !== 'undefined' && participantID && !local) {
logTracksForParticipant(store.getState()['features/base/tracks'], participantID, 'Track updated');
// Notify external API when remote participant mutes/unmutes themselves
const mediaType = isVideoTrack
? (jitsiTrack.getVideoType() === VIDEO_TYPE.DESKTOP ? 'desktop' : 'video')
: 'audio';
// Fire participantMuted event only if muted state actually changed
if (oldMutedState !== action.track.muted) {
const mediaType = isVideoTrack
? (jitsiTrack.getVideoType() === VIDEO_TYPE.DESKTOP ? 'desktop' : 'video')
: 'audio';
APP.API.notifyParticipantMuted(participantID, action.track.muted, mediaType, true);
APP.API.notifyParticipantMuted(participantID, action.track.muted, mediaType);
}
}
return result;

View File

@@ -4,7 +4,7 @@ import { isEqual, sortBy } from 'lodash-es';
import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout';
import { getAutoPinSetting } from '../../video-layout/functions.any';
import { MEDIA_TYPE } from '../media/constants';
import { getScreenshareParticipantIds } from '../participants/functions';
import { getLocalParticipant, getScreenshareParticipantIds } from '../participants/functions';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { isLocalTrackMuted } from './functions';
@@ -47,6 +47,13 @@ StateListenerRegistry.register(
/* listener */ (muted, store, previousMuted) => {
if (muted !== previousMuted) {
APP.API.notifyVideoMutedStatusChanged(muted);
// Also fire the participantMuted event for consistency
const localParticipant = getLocalParticipant(store.getState());
if (localParticipant) {
APP.API.notifyParticipantMuted(localParticipant.id, muted, 'video');
}
}
}
);

View File

@@ -301,7 +301,7 @@ const DialogWithTabs = ({
}
return null;
}, [ selectedTabIndex, tabStates ]);
}, [ selectedTabIndex, tabStates, tabs ]);
const closeIcon = useMemo(() => (
<ClickableIcon

View File

@@ -14,6 +14,7 @@ import { setColorAlpha } from '../../../base/util/helpers';
import { openChat, setFocusedTab } from '../../../chat/actions.web';
import Chat from '../../../chat/components/web/Chat';
import { ChatTabs } from '../../../chat/constants';
import CustomPanel from '../../../custom-panel/components/web/CustomPanel';
import { isFileUploadingEnabled, processFiles } from '../../../file-sharing/functions.any';
import MainFilmstrip from '../../../filmstrip/components/web/MainFilmstrip';
import ScreenshareFilmstrip from '../../../filmstrip/components/web/ScreenshareFilmstrip';
@@ -326,6 +327,7 @@ class Conference extends AbstractConference<IProps, any> {
{ _showVisitorsQueue && <VisitorsQueue />}
</div>
<ParticipantsPane />
<CustomPanel />
<ReactionAnimations />
</div>
);

View File

@@ -0,0 +1,14 @@
/**
* Action type to signal the closing of the custom panel.
*/
export const CUSTOM_PANEL_CLOSE = 'CUSTOM_PANEL_CLOSE';
/**
* Action type to signal the opening of the custom panel.
*/
export const CUSTOM_PANEL_OPEN = 'CUSTOM_PANEL_OPEN';
/**
* Action type to enable or disable the custom panel dynamically.
*/
export const SET_CUSTOM_PANEL_ENABLED = 'SET_CUSTOM_PANEL_ENABLED';

View File

@@ -0,0 +1,40 @@
import {
CUSTOM_PANEL_CLOSE,
CUSTOM_PANEL_OPEN,
SET_CUSTOM_PANEL_ENABLED
} from './actionTypes';
/**
* Action to close the custom panel.
*
* @returns {Object} The action object.
*/
export function close() {
return {
type: CUSTOM_PANEL_CLOSE
};
}
/**
* Action to open the custom panel.
*
* @returns {Object} The action object.
*/
export function open() {
return {
type: CUSTOM_PANEL_OPEN
};
}
/**
* Action to enable or disable the custom panel dynamically.
*
* @param {boolean} enabled - Whether the custom panel should be enabled.
* @returns {Object} The action object.
*/
export function setCustomPanelEnabled(enabled: boolean) {
return {
type: SET_CUSTOM_PANEL_ENABLED,
enabled
};
}

View File

@@ -0,0 +1,10 @@
/**
* Custom panel placeholder component.
* This file is overridden by jitsi-meet-branding at build time
* to provide the actual panel implementation with iframe content.
*
* @returns {null} This placeholder renders nothing.
*/
const CustomPanel = (): null => null;
export default CustomPanel;

View File

@@ -0,0 +1,10 @@
/**
* Custom panel button placeholder component.
* This file is overridden by jitsi-meet-branding at build time
* to provide the actual button implementation with custom icon.
*
* @returns {null} This placeholder renders nothing.
*/
const CustomPanelButton = (): null => null;
export default CustomPanelButton;

View File

@@ -0,0 +1,4 @@
/**
* Default width for the custom panel in pixels.
*/
export const DEFAULT_CUSTOM_PANEL_WIDTH = 315;

View File

@@ -0,0 +1,67 @@
import { IReduxState } from '../app/types';
import { DEFAULT_CUSTOM_PANEL_WIDTH } from './constants';
/**
* Returns whether the custom panel is enabled based on Redux state.
* The feature is disabled by default and can be enabled dynamically via console.
*
* @param {IReduxState} state - The Redux state.
* @returns {boolean} Whether the custom panel is enabled.
*/
export function isCustomPanelEnabled(state: IReduxState): boolean {
return Boolean(state['features/custom-panel']?.enabled);
}
/**
* Returns the custom panel URL.
* Override to provide the actual URL.
*
* @returns {string} The custom panel URL.
*/
export function getCustomPanelUrl(): string {
return '';
}
/**
* Returns the custom panel button icon.
* Override to provide the actual icon.
*
* @returns {Function | undefined} The icon component.
*/
export function getCustomPanelIcon(): Function | undefined {
return undefined;
}
/**
* Returns the configured panel width.
*
* @returns {number} The panel width in pixels.
*/
export function getCustomPanelConfiguredWidth(): number {
return DEFAULT_CUSTOM_PANEL_WIDTH;
}
/**
* Returns whether the custom panel is currently open.
*
* @param {IReduxState} state - The Redux state.
* @returns {boolean} Whether the custom panel is open.
*/
export function getCustomPanelOpen(state: IReduxState): boolean {
return Boolean(state['features/custom-panel']?.isOpen);
}
/**
* Returns the current panel width (0 if closed or disabled).
*
* @param {IReduxState} state - The Redux state.
* @returns {number} The panel width in pixels.
*/
export function getCustomPanelWidth(state: IReduxState): number {
if (!isCustomPanelEnabled(state)) {
return 0;
}
return getCustomPanelOpen(state) ? getCustomPanelConfiguredWidth() : 0;
}

View File

@@ -0,0 +1,29 @@
import { useSelector } from 'react-redux';
import CustomPanelButton from './components/web/CustomPanelButton';
import { isCustomPanelEnabled } from './functions';
/**
* Configuration for the custom panel toolbar button.
*/
const customPanel = {
key: 'custom-panel',
Content: CustomPanelButton,
group: 2
};
/**
* A hook that returns the custom panel button if the feature is enabled.
* Uses useSelector for reactive updates when the feature is toggled dynamically.
*
* @returns {Object | undefined} The button configuration or undefined if disabled.
*/
export function useCustomPanelButton() {
const enabled = useSelector(isCustomPanelEnabled);
if (enabled) {
return customPanel;
}
return undefined;
}

View File

@@ -0,0 +1,4 @@
/**
* Custom panel middleware placeholder.
* Override to add custom panel functionality.
*/

View File

@@ -0,0 +1,59 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CUSTOM_PANEL_CLOSE,
CUSTOM_PANEL_OPEN,
SET_CUSTOM_PANEL_ENABLED
} from './actionTypes';
/**
* The state of the custom panel feature.
*/
export interface ICustomPanelState {
/**
* Whether the custom panel feature is enabled.
* This can be toggled dynamically via console.
*/
enabled: boolean;
/**
* Whether the custom panel is currently open.
*/
isOpen: boolean;
}
const DEFAULT_STATE: ICustomPanelState = {
enabled: false,
isOpen: false
};
/**
* Listen for actions that mutate the custom panel state.
*/
ReducerRegistry.register(
'features/custom-panel', (state: ICustomPanelState = DEFAULT_STATE, action): ICustomPanelState => {
switch (action.type) {
case CUSTOM_PANEL_CLOSE:
return {
...state,
isOpen: false
};
case CUSTOM_PANEL_OPEN:
return {
...state,
isOpen: true
};
case SET_CUSTOM_PANEL_ENABLED:
return {
...state,
enabled: action.enabled
};
default:
return state;
}
}
);

View File

@@ -248,7 +248,7 @@ export function getRecordButtonProps(state: IReduxState) {
// a button can be disabled/enabled if enableFeaturesBasedOnToken
// is on or if the livestreaming is running.
let disabled = false;
let tooltip = '';
let tooltip = isRecordingRunning(state) ? 'dialog.stopRecording' : 'dialog.startRecording';
// If the containing component provides the visible prop, that is one
// above all, but if not, the button should be autonomus and decide on

View File

@@ -123,7 +123,8 @@ export const MAIN_TOOLBAR_BUTTONS_PRIORITY = [
'embedmeeting',
'feedback',
'download',
'help'
'help',
'custom-panel'
];
export const TOOLBAR_TIMEOUT = 4000;
@@ -148,6 +149,7 @@ export const TOOLBAR_BUTTONS: ToolbarButton[] = [
'camera',
'chat',
'closedcaptions',
'custom-panel',
'desktop',
'download',
'embedmeeting',

View File

@@ -15,6 +15,7 @@ import { isToggleCameraEnabled } from '../base/tracks/functions.web';
import { toggleChat } from '../chat/actions.web';
import { isChatDisabled } from '../chat/functions';
import { useChatButton } from '../chat/hooks.web';
import { useCustomPanelButton } from '../custom-panel/hooks.web';
import { useEmbedButton } from '../embed-meeting/hooks';
import { useEtherpadButton } from '../etherpad/hooks';
import { useFeedbackButton } from '../feedback/hooks.web';
@@ -292,6 +293,7 @@ export function useToolboxButtons(
const feedback = useFeedbackButton();
const _download = useDownloadButton();
const _help = useHelpButton();
const customPanel = useCustomPanelButton();
const buttons: { [key in ToolbarButton]?: IToolboxButton; } = {
microphone,
@@ -326,7 +328,8 @@ export function useToolboxButtons(
embedmeeting: embed,
feedback,
download: _download,
help: _help
help: _help,
'custom-panel': customPanel
};
const buttonKeys = Object.keys(buttons) as ToolbarButton[];

View File

@@ -19,6 +19,7 @@ export interface IToolboxNativeButton {
export type ToolbarButton = 'camera' |
'chat' |
'closedcaptions' |
'custom-panel' |
'desktop' |
'download' |
'embedmeeting' |

View File

@@ -73,11 +73,6 @@ export function muteRemote(participantId: string, mediaType: MediaType) {
const muteMediaType = mediaType === MEDIA_TYPE.SCREENSHARE ? 'desktop' : mediaType;
dispatch(muteRemoteParticipant(participantId, muteMediaType));
// Notify external API that participant was muted by moderator
if (typeof APP !== 'undefined') {
APP.API.notifyParticipantMuted(participantId, true, muteMediaType, false);
}
};
}

View File

@@ -40,13 +40,18 @@ module:hook('muc-occupant-left', function (event)
end
-- seems the room only has jibri and transcriber, add a timeout to destroy the room
if room.empty_destroy_timer then
room.empty_destroy_timer:stop();
end
room.empty_destroy_timer = module:add_timer(EMPTY_TIMEOUT, function()
if room.destroying then return end
room:destroy(nil, 'Empty room with recording and/or transcribing.');
module:log('info',
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled',
room.jid, EMPTY_TIMEOUT);
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled. By %s',
room.jid, EMPTY_TIMEOUT, room.empty_destroy_timer);
end)
module:log('info', 'Added room destroy timer %s for %s', room.empty_destroy_timer, room.jid);
end, -100); -- the last thing to execute
module:hook('muc-room-destroyed', function (event)

View File

@@ -212,39 +212,39 @@ local function filterTranscriptionResult(event)
-- Do not fire the event, but forward the message
return
end
end
if msg_obj.transcript ~= nil then
local transcription = msg_obj;
if msg_obj.transcript ~= nil then
local transcription = msg_obj;
-- in case of the string matching optimization above failed
if transcription.is_interim then
return;
-- in case of the string matching optimization above failed
if transcription.is_interim then
return;
end
-- TODO what if we have multiple alternative transcriptions not just 1
local text_message = transcription.transcript[1].text;
--do not send empty messages
if text_message == '' then
return;
end
local user_id = transcription.participant.id;
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
transcription.jid = who and who.jid;
transcription.session_id = room._data.meetingId;
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
if tenant then
transcription.fqn = tenant..'/'..conference_name;
else
transcription.fqn = conference_name;
end
transcription.customer_id = id;
return module:fire_event('jitsi-transcript-received', {
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
end
-- TODO what if we have multiple alternative transcriptions not just 1
local text_message = transcription.transcript[1].text;
--do not send empty messages
if text_message == '' then
return;
end
local user_id = transcription.participant.id;
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
transcription.jid = who and who.jid;
transcription.session_id = room._data.meetingId;
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
if tenant then
transcription.fqn = tenant..'/'..conference_name;
else
transcription.fqn = conference_name;
end
transcription.customer_id = id;
return module:fire_event('jitsi-transcript-received', {
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
end
return module:fire_event('jitsi-endpoint-message-received', {

View File

@@ -674,6 +674,10 @@ export class Participant {
return new IframeAPI(this);
}
async getRoomMetadata() {
return this.execute(() => window.APP?.conference?._room?.getMetadataHandler()?.getMetadata());
}
/**
* Hangups the participant by leaving the page. base.html is an empty page on all deployments.
*/

View File

@@ -8,6 +8,8 @@ export type ITestProperties = {
description?: string;
/** The test requires the webhook proxy to be available. */
requireWebhookProxy: boolean;
/** Whether the test should be retried. */
retry: boolean;
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
useJaas: boolean;
/** The test uses the webhook proxy if available. */
@@ -18,6 +20,7 @@ export type ITestProperties = {
const defaultProperties: ITestProperties = {
useWebhookProxy: false,
requireWebhookProxy: false,
retry: false,
useJaas: false,
usesBrowsers: [ 'p1' ]
};

View File

@@ -176,6 +176,7 @@ export default class WebhookProxy {
* @param value
*/
set defaultMeetingSettings(value: {
asyncTranscriptions?: boolean;
autoAudioRecording?: boolean;
autoTranscriptions?: boolean;
autoVideoRecording?: boolean;

View File

@@ -7,6 +7,7 @@ import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
retry: true,
useJaas: true
});

View File

@@ -8,182 +8,201 @@ import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
retry: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
describe('Transcription', () => {
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
for (const asyncTranscriptions of [ false, true ]) {
describe(`Transcription (async=${asyncTranscriptions})`, () => {
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
it('setup', async () => {
const room = ctx.roomName;
it('setup', async () => {
const room = ctx.roomName;
webhooksProxy = ctx.webhooksProxy;
webhooksProxy = ctx.webhooksProxy;
webhooksProxy.defaultMeetingSettings = { asyncTranscriptions };
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, moderator: true }),
iFrameApi: true });
p1 = await joinJaasMuc({
name: 'p1',
token: t({ room, moderator: true }),
iFrameApi: true
});
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room }),
iFrameApi: true }, {
configOverwrite: {
startWithAudioMuted: true
const roomMetadata = await p1.getRoomMetadata();
if (asyncTranscriptions) {
expect(roomMetadata.asyncTranscription).toBe(true);
} else {
expect(roomMetadata.asyncTranscription).toBeFalsy();
}
p2 = await joinJaasMuc({
name: 'p2',
token: t({ room }),
iFrameApi: true
}, {
configOverwrite: {
startWithAudioMuted: true
}
});
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame(),
]);
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
await Promise.all([
p1.switchToMainFrame(),
p2.switchToMainFrame(),
]);
it('toggle subtitles', async () => {
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
});
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
it('toggle subtitles', async () => {
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await checkReceivingChunks(p1, p2, webhooksProxy);
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
it('set subtitles on and off', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await checkReceivingChunks(p1, p2, webhooksProxy);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('setSubtitles', false);
await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
it('start/stop transcriptions via recording', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
let allTranscriptionStatusChanged: Promise<any>[] = [];
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
it('set subtitles on and off', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('setSubtitles', false);
await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2'
}));
let result = await Promise.allSettled(allTranscriptionStatusChanged);
expect(result.length).toBe(2);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(true);
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
await checkReceivingChunks(p1, p2, webhooksProxy);
it('start/stop transcriptions via recording', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
allTranscriptionStatusChanged = [];
let allTranscriptionStatusChanged: Promise<any>[] = [];
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2'
}));
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2'
}));
result = await Promise.allSettled(allTranscriptionStatusChanged);
let result = await Promise.allSettled(allTranscriptionStatusChanged);
expect(result.length).toBe(2);
expect(result.length).toBe(2);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(false);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(true);
});
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
allTranscriptionStatusChanged = [];
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p1'
}));
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 10000,
timeoutMsg: 'transcribingStatusChanged event not received on p2'
}));
result = await Promise.allSettled(allTranscriptionStatusChanged);
expect(result.length).toBe(2);
result.forEach(e => {
// @ts-ignore
expect(e.value.on).toBe(false);
});
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
expect(event.data.preAuthenticatedLink).toBeDefined();
});
await p1.getIframeAPI().executeCommand('hangup');
await p2.getIframeAPI().executeCommand('hangup');
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect('TRANSCRIPTION_UPLOADED').toBe(event.eventType);
expect(event.data.preAuthenticatedLink).toBeDefined();
});
});
}
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy) {
const allTranscripts: Promise<any>[] = [];
allTranscripts.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
/**
*
* @param p1
* @param p2
* @param webhooksProxy
* @param expectName Whether to expect the events to contain the name of the participant. Currently, async
* transcriptions do not include the name. TODO: remove this parameter when async transcription events are fixed.
*/
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, expectName = true) {
const p1Promise = p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p1 side'
}));
});
allTranscripts.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
const p2Promise = p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
}));
});
// TRANSCRIPTION_CHUNK_RECEIVED webhook
allTranscripts.push((async () => {
const webhookPromise = async () => {
const event: {
data: {
final: string;
@@ -198,42 +217,36 @@ async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksPr
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_CHUNK_RECEIVED');
expect('TRANSCRIPTION_CHUNK_RECEIVED').toBe(event.eventType);
expect(event.eventType).toBe('TRANSCRIPTION_CHUNK_RECEIVED');
event.data.stable = event.data.final;
return event;
})());
};
const result = await Promise.allSettled(allTranscripts);
expect(result.length).toBeGreaterThan(0);
// @ts-ignore
const firstEntryData = result[0].value.data;
const stable = firstEntryData.stable || firstEntryData.final;
const language = firstEntryData.language;
const messageID = firstEntryData.messageID;
const [ p1Event, p2Event, webhookEvent ] = await Promise.all([ p1Promise, p2Promise, await webhookPromise() ]);
const p1Id = await p1.getEndpointId();
result.map(r => {
// @ts-ignore
const v = r.value;
const p1Transcript = p1Event.data.stable || p1Event.data.final;
const p2Transcript = p2Event.data.stable || p2Event.data.final;
const webhookTranscript = webhookEvent.data.stable || webhookEvent.data.final;
expect(v).toBeDefined();
expect(p2Transcript.includes(p1Transcript) || p1Transcript.includes(p2Transcript)).toBe(true);
expect(webhookTranscript.includes(p1Transcript) || p1Transcript.includes(webhookTranscript)).toBe(true);
return v.data;
}).forEach(tr => {
const checkTranscripts = stable.includes(tr.stable || tr.final) || (tr.stable || tr.final).includes(stable);
expect(p2Event.data.language).toBe(p1Event.data.language);
expect(webhookEvent.data.language).toBe(p1Event.data.language);
if (!checkTranscripts) {
console.log('received events', JSON.stringify(result));
}
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
expect(webhookEvent.data.messageID).toBe(p1Event.data.messageID);
expect(checkTranscripts).toBe(true);
expect(tr.language).toBe(language);
expect(tr.messageID).toBe(messageID);
expect(tr.participant.id).toBe(p1Id);
expect(tr.participant.name).toBe(p1.name);
});
expect(p1Event.data.participant.id).toBe(p1Id);
expect(p2Event.data.participant.id).toBe(p1Id);
expect(webhookEvent.data.participant.id).toBe(p1Id);
if (expectName) {
expect(p1Event.data.participant.name).toBe(p1.name);
expect(p2Event.data.participant.name).toBe(p1.name);
expect(webhookEvent.data.participant.name).toBe(p1.name);
}
}

View File

@@ -10,6 +10,7 @@ import {
} from '../../helpers/participants';
setTestProperties(__filename, {
retry: true,
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});

View File

@@ -288,9 +288,23 @@ export const config: WebdriverIO.MultiremoteConfig = {
keepAlive.forEach(clearInterval);
},
beforeSession(c, capabilities_, specs_, cid) {
async beforeSession(c, capabilities_, spec, cid) {
const originalBefore = c.before;
if (spec && spec.length == 1) {
const testFilePath = spec[0].replace(/^file:\/\//, '');
const testProperties = await getTestProperties(testFilePath);
if (testProperties.retry) {
c.specFileRetries = 1;
c.specFileRetriesDeferred = true;
c.specFileRetriesDelay = 1;
console.log(`Enabling retry for ${testFilePath}`);
}
} else {
console.log('No test file or multiple test files specified, will not enable retries');
}
if (!originalBefore || !Array.isArray(originalBefore) || originalBefore.length !== 1) {
console.warn('No before hook found or more than one found, skipping');