Compare commits

...

36 Commits

Author SHA1 Message Date
Mihaela Dumitru
28c72bfa7f fix(toolbar): remove hang up from prejoin app 2026-02-06 12:48:29 +01:00
dependabot[bot]
24c78cf8ff chore(deps-dev): bump webpack from 5.95.0 to 5.105.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.95.0 to 5.105.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.95.0...v5.105.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.105.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-06 08:58:18 +01:00
Stephan Paternotte
147e64106d lang: Dutch translation updates.
* Update main-nl.json

Full update to latest state of main.json including Dutch translation updates.
Validated with JSONLint

* Fixes lint.

---------

Co-authored-by: Дамян Минков <damencho@jitsi.org>
2026-02-05 16:58:54 -06:00
Hristo Terezov
3d53f2e4c5 fix(PiP): Mac OS minimize not working
When we minimize the meeting window on Mac OS via the yellow button, 2 requests for PiP are triggered one after another because of the blur and the visibilitychange handlers. If we implement in Electron to focus the meeting window on the pipLeft event this will lead to the first request to exit triggering the pipLeft event after the second request is triggered which will actually bring the window back because we focus on pipLeft event. This behaviour breaks the ability to minimize a window.

The current fix will prevent us for sending 2 requests for PiP one after another by skipping the second unnecessary request.
2026-02-05 12:36:07 -06:00
damencho
e60bfc573a fix(muc_auth_ban): Adds checks for jaas. 2026-02-04 06:14:30 -06:00
damencho
796a7efa7f fix(tests): Make tests to go through pre-join automatically. 2026-02-03 17:58:28 -06:00
damencho
fb075c376d fix(prejoin): Handles different ways of disabling prejoin.
Now handles using `config.prejoinConfig.enabled=false` and `config.prejoinConfig={enabled:false}.

Fixes #16892.
2026-02-03 17:58:28 -06:00
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
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
Calin-Teodor
fc170891cb feat(subtitles): rework how isAsyncTranscriptionEnabled is being used 2026-01-22 14:00:06 +02:00
Calin-Teodor
775cc52f66 feat(subtitles): hide translation UI for both web and native 2026-01-22 10:50:03 +02:00
Calin-Teodor
3baede6ff1 feat(config): add reducedUIEnabled config to control web side 2026-01-22 10:44:15 +02:00
Дамян Минков
9462a9ce36 feat(breakout-rooms): Adds some jid validation on joining. (#16858)
* feat(breakout-rooms): Adds some jid validation on joining.

* squash: Fix comments.

* Update resources/prosody-plugins/mod_muc_breakout_rooms.lua

Co-authored-by: bgrozev <boris@jitsi.org>

* Update resources/prosody-plugins/mod_muc_breakout_rooms.lua

Co-authored-by: bgrozev <boris@jitsi.org>

---------

Co-authored-by: bgrozev <boris@jitsi.org>
2026-01-21 19:53:27 -06:00
Aaron van Meerten
4713062200 feat(muc_auth_ban): add metrics and logs for error responses from access backend (#16853) 2026-01-21 13:47:32 +01:00
Calinteodor
65eb2a2899 feat(base/modal): small updates around JitsiScreen footer (#16727)
*UI updates and reworks around JitsiScreen footer and its children.
2026-01-21 13:05:05 +02:00
Boris Grozev
f0452d05b9 feat: Hide the translation UI when asyncTranscription is used. 2026-01-20 11:56:13 -06:00
damencho
1102f4205a fix: Fixes setting backend recording when async transcriptions are not on. 2026-01-20 11:56:06 -06:00
Calinteodor
447def54c8 feat(chat/native): add Closed Captions tab (#16787)
* Added CC tab inside Chat screen and some UI fixes on mobile.
2026-01-20 13:33:46 +02:00
121 changed files with 4405 additions and 1395 deletions

View File

@@ -927,6 +927,9 @@ var config = {
// [ 'microphone', 'camera' ]
// ],
// Enable reduced UI on web.
// reducedUIEnabled: true,
// Overrides the buttons displayed in the main toolbar for reduced UI.
// When there isn't an override for a certain configuration the default jitsi-meet configuration will be used.
// The order of the buttons in the array is preserved.

View File

@@ -1407,7 +1407,7 @@ PODS:
- Yoga
- react-native-performance (5.1.2):
- React-Core
- react-native-safe-area-context (5.5.2):
- react-native-safe-area-context (5.6.1):
- React-Core
- react-native-slider (4.5.6):
- DoubleConversion
@@ -2271,7 +2271,7 @@ SPEC CHECKSUMS:
react-native-orientation-locker: dbd3f6ddbe9e62389cb0807dc2af63f6c36dec36
react-native-pager-view: 11662c698c8f11d39e05891316d2a144fa00adc4
react-native-performance: 125a96c145e29918b55b45ce25cbba54f1e24dcd
react-native-safe-area-context: 0f7bf11598f9a61b7ceac8dc3f59ef98697e99e1
react-native-safe-area-context: 2243039f43d10cb1ea30ec5ac57fc6d1448413f4
react-native-slider: 1205801a8d29b28cacc14eef08cb120015cdafcb
react-native-video: eb861d67a71dfef1bbf6086a811af5f338b13781
react-native-webrtc: e8f0ce746353adc2744a2b933645e1aeb41eaa74

File diff suppressed because it is too large Load Diff

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));
},
@@ -790,7 +804,7 @@ function initCommands() {
}
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(true, false, null));
APP.store.dispatch(setRequestingSubtitles(true, false, null, true));
}
},
@@ -812,7 +826,7 @@ function initCommands() {
}
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(false, false, null));
APP.store.dispatch(setRequestingSubtitles(false, false, null, true));
}
if (mode === 'local') {
@@ -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
});
}

2133
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
@@ -97,7 +97,7 @@
"react-native-pager-view": "6.8.1",
"react-native-paper": "5.10.3",
"react-native-performance": "5.1.2",
"react-native-safe-area-context": "5.5.2",
"react-native-safe-area-context": "5.6.1",
"react-native-screens": "4.11.1",
"react-native-sound": "https://github.com/jitsi/react-native-sound.git#ea13c97b5c2a4ff5e0d9bacbd9ff5e4457fe2c3c",
"react-native-splash-view": "0.0.18",
@@ -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",
@@ -194,7 +194,7 @@
"typescript": "5.7.2",
"unorm": "1.6.0",
"webdriverio": "9.22.0",
"webpack": "5.95.0",
"webpack": "5.105.0",
"webpack-bundle-analyzer": "4.4.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0"

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

@@ -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

@@ -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

@@ -559,6 +559,7 @@ export interface IConfig {
skipConsentInMeeting?: boolean;
suggestRecording?: boolean;
};
reducedUIEnabled?: boolean;
reducedUImainToolbarButtons?: Array<string>;
remoteVideoMenu?: {
disableDemote?: boolean;

View File

@@ -215,6 +215,7 @@ export default [
'recordings.showPrejoinWarning',
'recordings.showRecordingLink',
'recordings.suggestRecording',
'reducedUIEnabled',
'reducedUImainToolbarButtons',
'replaceParticipant',
'resolution',

View File

@@ -387,7 +387,8 @@ export function setConfigFromURLParams(
// When not in an iframe, start without media if the pre-join page is not enabled.
if (!isEmbedded()
&& 'config.prejoinConfig.enabled' in params && config.prejoinConfig?.enabled === false) {
&& ('config.prejoinConfig' in params || 'config.prejoinConfig.enabled' in params)
&& config.prejoinConfig?.enabled === false) {
logger.warn('Using prejoinConfig.enabled config URL overwrite implies starting without media.');
config.disableInitialGUM = true;
}

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

@@ -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

@@ -17,7 +17,7 @@ interface IProps {
/**
* The children component(s) of the Modal, to be rendered.
*/
children: React.ReactNode;
children?: React.ReactNode;
/**
* Additional style to be appended to the KeyboardAvoidingView content container.
@@ -63,7 +63,7 @@ const JitsiScreen = ({
footerComponent,
hasBottomTextInput = false,
hasExtraHeaderHeight = false,
safeAreaInsets = [ 'left', 'right' ],
safeAreaInsets = [ 'bottom', 'left', 'right' ],
style
}: IProps) => {
const renderContent = () => (
@@ -78,8 +78,8 @@ const JitsiScreen = ({
edges = { safeAreaInsets }
style = { styles.safeArea }>
{ children }
{ footerComponent?.() }
</SafeAreaView>
{ footerComponent?.() }
</JitsiKeyboardAvoidingView>
);

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

@@ -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 {
@@ -43,6 +44,7 @@ export function clientResized(clientWidth: number, clientHeight: number) {
if (navigator.product !== 'ReactNative') {
const state = getState();
const { reducedUIEnabled = true } = state['features/base/config'];
const { isOpen: isChatOpen, width } = state['features/chat'];
if (isChatOpen) {
@@ -50,8 +52,9 @@ export function clientResized(clientWidth: number, clientHeight: number) {
}
availableWidth -= getParticipantsPaneWidth(state);
availableWidth -= getCustomPanelWidth(state);
dispatch(setReducedUI(availableWidth, clientHeight));
reducedUIEnabled && dispatch(setReducedUI(availableWidth, clientHeight));
}
batch(() => {
@@ -112,7 +115,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

@@ -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

@@ -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

@@ -22,7 +22,7 @@ export function openChat(participant?: IParticipant | undefined | Object, disabl
if (disablePolls) {
navigate(screen.conference.chat);
} else {
navigate(screen.conference.chatandpolls.main);
navigate(screen.conference.chatTabs.main);
}
dispatch(setFocusedTab(ChatTabs.CHAT));

View File

@@ -0,0 +1,111 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import { openDialog } from '../../base/dialog/actions';
import { IMessageGroup, groupMessagesBySender } from '../../base/util/messageGrouping';
// @ts-ignore
import { StartRecordingDialog } from '../../recording/components/Recording';
import { setRequestingSubtitles } from '../../subtitles/actions.any';
import { canStartSubtitles } from '../../subtitles/functions.any';
import { ISubtitle } from '../../subtitles/types';
import { isTranscribing } from '../../transcribing/functions';
export type AbstractProps = {
canStartSubtitles: boolean;
filteredSubtitles: ISubtitle[];
groupedSubtitles: IMessageGroup<ISubtitle>[];
isButtonPressed: boolean;
isTranscribing: boolean;
startClosedCaptions: () => void;
};
const AbstractClosedCaptions = (Component: ComponentType<AbstractProps>) => () => {
const dispatch = useDispatch();
const subtitles = useSelector((state: IReduxState) => state['features/subtitles'].subtitlesHistory);
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
const selectedLanguage = language?.replace('translation-languages:', '');
const _isTranscribing = useSelector(isTranscribing);
const _canStartSubtitles = useSelector(canStartSubtitles);
const [ isButtonPressed, setButtonPressed ] = useState(false);
const subtitlesError = useSelector((state: IReduxState) => state['features/subtitles']._hasError);
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
const filteredSubtitles = useMemo(() => {
// First, create a map of transcription messages by message ID
const transcriptionMessages = new Map(
subtitles
.filter(s => s.isTranscription)
.map(s => [ s.id, s ])
);
if (!selectedLanguage) {
// When no language is selected, show all original transcriptions
return Array.from(transcriptionMessages.values());
}
// Then, create a map of translation messages by message ID
const translationMessages = new Map(
subtitles
.filter(s => !s.isTranscription && s.language === selectedLanguage)
.map(s => [ s.id, s ])
);
// When a language is selected, for each transcription message:
// 1. Use its translation if available
// 2. Fall back to the original transcription if no translation exists
return Array.from(transcriptionMessages.values())
.filter((m: ISubtitle) => !m.interim)
.map(m => translationMessages.get(m.id) ?? m);
}, [ subtitles, selectedLanguage ]);
const groupedSubtitles = useMemo(() =>
groupMessagesBySender(filteredSubtitles), [ filteredSubtitles ]);
const startClosedCaptions = useCallback(() => {
if (isAsyncTranscriptionEnabled) {
dispatch(openDialog('StartRecordingDialog', StartRecordingDialog, {
recordAudioAndVideo: false
}));
} else {
if (isButtonPressed) {
return;
}
dispatch(setRequestingSubtitles(true, false, null));
setButtonPressed(true);
}
}, [ isAsyncTranscriptionEnabled, dispatch, isButtonPressed, openDialog, setButtonPressed ]);
useEffect(() => {
if (subtitlesError && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ subtitlesError, isButtonPressed, isAsyncTranscriptionEnabled ]);
useEffect(() => {
if (!_isTranscribing && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ _isTranscribing, isButtonPressed, isAsyncTranscriptionEnabled ]);
useEffect(() => {
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
}, [ isButtonPressed, isAsyncTranscriptionEnabled ]);
return (
<Component
canStartSubtitles = { _canStartSubtitles }
filteredSubtitles = { filteredSubtitles }
groupedSubtitles = { groupedSubtitles }
isButtonPressed = { isButtonPressed }
isTranscribing = { _isTranscribing }
startClosedCaptions = { startClosedCaptions } />
);
};
export default AbstractClosedCaptions;

View File

@@ -1,39 +1,29 @@
/* eslint-disable react/no-multi-comp */
import { Route, useIsFocused } from '@react-navigation/native';
import React, { Component, useEffect } from 'react';
import { connect } from 'react-redux';
import { connect, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../base/styles/functions.native';
import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter';
import { getUnreadPollCount } from '../../../polls/functions';
import { pollsStyles } from '../../../polls/components/native/styles';
import { closeChat, sendMessage } from '../../actions.native';
import { getUnreadFilesCount } from '../../functions';
import { ChatTabs } from '../../constants';
import { IChatProps as AbstractProps } from '../../types';
import ChatInputBar from './ChatInputBar';
import MessageContainer from './MessageContainer';
import MessageRecipient from './MessageRecipient';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* The number of unread file messages.
*/
_unreadFilesCount: number;
/**
* The number of unread messages.
*/
_unreadMessagesCount: number;
/**
* The number of unread polls.
*/
_unreadPollsCount: number;
/**
* Default prop for navigating between screen components(React Navigation).
*/
@@ -62,6 +52,7 @@ class Chat extends Component<IProps> {
// Bind event handlers so they are only bound once per instance.
this._onSendMessage = this._onSendMessage.bind(this);
this._renderFooter = this._renderFooter.bind(this);
}
/**
@@ -76,14 +67,10 @@ class Chat extends Component<IProps> {
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
/* eslint-disable react/jsx-no-bind */
footerComponent = { () =>
<ChatInputBar onSend = { this._onSendMessage } />
}
footerComponent = { this._renderFooter }
hasBottomTextInput = { true }
hasExtraHeaderHeight = { true }
style = { styles.chatContainer }>
style = { pollsStyles.pollPaneContainer as StyleType }>
{/* @ts-ignore */}
<MessageContainer messages = { _messages } />
<MessageRecipient privateMessageRecipient = { privateMessageRecipient } />
@@ -91,6 +78,16 @@ class Chat extends Component<IProps> {
);
}
/**
* Renders the footer component.
*
* @private
* @returns {React$Element<*>}
*/
_renderFooter() {
return <ChatInputBar onSend = { this._onSendMessage } />;
}
/**
* Sends a text message.
*
@@ -113,9 +110,7 @@ class Chat extends Component<IProps> {
* @private
* @returns {{
* _messages: Array<Object>,
* _unreadMessagesCount: number,
* _unreadPollsCount: number,
* _unreadFilesCount: number
* _unreadMessagesCount: number
* }}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
@@ -123,34 +118,34 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
return {
_messages: messages,
_unreadMessagesCount: unreadMessagesCount,
_unreadPollsCount: getUnreadPollCount(state),
_unreadFilesCount: getUnreadFilesCount(state)
_unreadMessagesCount: unreadMessagesCount
};
}
export default translate(connect(_mapStateToProps)((props: IProps) => {
const { _unreadMessagesCount, _unreadPollsCount, _unreadFilesCount, dispatch, navigation, t } = props;
const totalUnread = _unreadMessagesCount + _unreadPollsCount + _unreadFilesCount;
const unreadMessagesNr = totalUnread > 0;
const { _unreadMessagesCount, dispatch, navigation, t } = props;
const isChatTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.CHAT);
const isFocused = useIsFocused();
const activeUnreadMessagesNr = !isChatTabFocused && _unreadMessagesCount > 0;
useEffect(() => {
navigation?.setOptions({
tabBarLabel: () => (
<TabBarLabelCounter
activeUnreadNr = { unreadMessagesNr }
activeUnreadNr = { activeUnreadMessagesNr }
isFocused = { isFocused }
label = { t('chat.tabs.chat') }
unreadCount = { totalUnread } />
unreadCount = { _unreadMessagesCount } />
)
});
return () => {
isFocused && dispatch(closeChat());
};
}, [ isFocused, _unreadMessagesCount, _unreadPollsCount, _unreadFilesCount ]);
}, [ isFocused, _unreadMessagesCount ]);
return (
<Chat { ...props } />

View File

@@ -43,7 +43,7 @@ class ChatButton extends AbstractButton<IProps> {
override _handleClick() {
this.props._isPollsDisabled
? navigate(screen.conference.chat)
: navigate(screen.conference.chatandpolls.main);
: navigate(screen.conference.chatTabs.main);
}
/**

View File

@@ -7,7 +7,6 @@ import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconSend } from '../../../base/icons/svg';
import { ASPECT_RATIO_WIDE } from '../../../base/responsive-ui/constants';
import IconButton from '../../../base/ui/components/native/IconButton';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
@@ -85,14 +84,6 @@ class ChatInputBar extends Component<IProps, IState> {
* @inheritdoc
*/
override render() {
let inputBarStyles;
if (this.props.aspectRatio === ASPECT_RATIO_WIDE) {
inputBarStyles = styles.inputBarWide;
} else {
inputBarStyles = styles.inputBarNarrow;
}
if (this.props._isSendGroupChatDisabled && !this.props._privateMessageRecipientId) {
return (
<View
@@ -109,7 +100,7 @@ class ChatInputBar extends Component<IProps, IState> {
<View
id = 'chat-input'
style = { [
inputBarStyles,
styles.inputBar,
this.state.addPadding ? styles.extraBarPadding : null
] as ViewStyle[] }>
<Input
@@ -129,6 +120,7 @@ class ChatInputBar extends Component<IProps, IState> {
id = { this.props.t('chat.sendButton') }
onPress = { this._onSubmit }
src = { IconSend }
style = { styles.sendButton }
type = { BUTTON_TYPES.PRIMARY } />
</View>
);

View File

@@ -0,0 +1,130 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { TouchableHighlight, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowRight, IconSubtitles } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../base/styles/functions.any';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter';
import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { ChatTabs } from '../../constants';
import AbstractClosedCaptions, { AbstractProps } from '../AbstractClosedCaptions';
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
import { closedCaptionsStyles } from './styles';
/**
* Component that displays the closed captions interface.
*
* @returns {JSX.Element} - The ClosedCaptions component.
*/
const ClosedCaptions = ({
canStartSubtitles,
filteredSubtitles,
groupedSubtitles,
isButtonPressed,
isTranscribing,
startClosedCaptions
}: AbstractProps): JSX.Element => {
const navigation = useNavigation();
const { t } = useTranslation();
const isCCTabFocused = useSelector((state: IReduxState) => state['features/chat'].focusedTab === ChatTabs.CLOSED_CAPTIONS);
const selectedLanguage = useSelector((state: IReduxState) => state['features/subtitles']._language);
const navigateToLanguageSelect = useCallback(() => {
navigate(screen.conference.subtitles);
}, [ navigation, screen ]);
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
useEffect(() => {
navigation?.setOptions({
tabBarLabel: () => (
<TabBarLabelCounter
isFocused = { isCCTabFocused }
label = { t('chat.tabs.closedCaptions') } />
)
});
}, [ isCCTabFocused, navigation, t ]);
const getContentContainerStyle = () => {
if (isTranscribing) {
return closedCaptionsStyles.transcribingContainer as StyleType;
}
return closedCaptionsStyles.emptyContentContainer as StyleType;
};
const renderContent = () => {
if (!isTranscribing) {
if (canStartSubtitles) {
return (
<View style = { closedCaptionsStyles.emptyContent as ViewStyle }>
<Button
accessibilityLabel = { t('closedCaptionsTab.startClosedCaptionsButton') }
disabled = { isButtonPressed }
labelKey = 'closedCaptionsTab.startClosedCaptionsButton'
onClick = { startClosedCaptions }
type = { BUTTON_TYPES.PRIMARY } />
</View>
);
}
return (
<View style = { closedCaptionsStyles.emptyContent as ViewStyle }>
<Icon
color = { BaseTheme.palette.icon03 }
size = { 100 }
src = { IconSubtitles } />
<Text style = { [ closedCaptionsStyles.emptyStateText, { marginTop: BaseTheme.spacing[3] } ] }>
{ t('closedCaptionsTab.emptyState') }
</Text>
</View>
);
}
return (
<>
{
// Hide the "Translate to" option when asyncTranscription is enabled
!isAsyncTranscriptionEnabled && <View style = { closedCaptionsStyles.languageButtonContainer as ViewStyle }>
<Text style = { closedCaptionsStyles.languageButtonText }>{ t('transcribing.translateTo') }:</Text>
<TouchableHighlight onPress = { navigateToLanguageSelect }>
<View style = { closedCaptionsStyles.languageButtonContent as ViewStyle }>
<Text style = { closedCaptionsStyles.languageButtonText }>{ t(selectedLanguage ?? 'transcribing.subtitlesOff') }</Text>
<Icon
size = { 24 }
src = { IconArrowRight } />
</View>
</TouchableHighlight>
</View>
}
<View style = { closedCaptionsStyles.messagesContainer as ViewStyle }>
<SubtitlesMessagesContainer
groups = { groupedSubtitles }
messages = { filteredSubtitles } />
</View>
</>
);
};
return (
<JitsiScreen
contentContainerStyle = { getContentContainerStyle() }
disableForcedKeyboardDismiss = { true }
hasExtraHeaderHeight = { true }
style = { closedCaptionsStyles.container as StyleType }>
{ renderContent() }
</JitsiScreen>
);
};
export default AbstractClosedCaptions(ClosedCaptions);

View File

@@ -44,19 +44,23 @@ class MessageContainer extends Component<IProps, any> {
*/
override render() {
const data = this._getMessagesGroupedBySender();
const noMessages = data.length === 0;
return (
<FlatList
ListEmptyComponent = { this._renderListEmptyComponent }
bounces = { false }
data = { data }
// @ts-ignore
contentContainerStyle = { noMessages && styles.emptyListContentContainer }
data = { data }
// Workaround for RN bug:
// https://github.com/facebook/react-native/issues/21196
inverted = { Boolean(data.length) }
keyExtractor = { this._keyExtractor }
keyboardShouldPersistTaps = 'handled'
renderItem = { this._renderMessageGroup } />
renderItem = { this._renderMessageGroup }
style = { noMessages && styles.emptyListStyle } />
);
}

View File

@@ -67,8 +67,8 @@ class PrivateMessageButton extends AbstractButton<IProps, any> {
? navigate(screen.conference.chat, {
privateMessageRecipient: this.props._participant
})
: navigate(screen.conference.chatandpolls.main, {
screen: screen.conference.chatandpolls.tab.chat,
: navigate(screen.conference.chatTabs.main, {
screen: screen.conference.chatTabs.tab.chat,
params: {
privateMessageRecipient: this.props._participant
}

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Text, View, ViewStyle } from 'react-native';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import { ISubtitle } from '../../../subtitles/types';
import { closedCaptionsStyles } from './styles';
interface IProps extends ISubtitle {
showDisplayName: boolean;
}
export default function SubtitleMessage({ participantId, text, timestamp, interim, showDisplayName }: IProps) {
const participantName = useSelector((state: IReduxState) =>
getParticipantDisplayName(state, participantId));
const containerStyle: ViewStyle[] = [
closedCaptionsStyles.subtitleMessageContainer as ViewStyle
];
if (interim) {
containerStyle.push(closedCaptionsStyles.subtitleMessageInterim as ViewStyle);
}
return (
<View style = { containerStyle }>
<View style = { closedCaptionsStyles.subtitleMessageContent as ViewStyle }>
{
showDisplayName && (
<Text style = { closedCaptionsStyles.subtitleMessageHeader }>
{ participantName }
</Text>
)
}
<Text style = { closedCaptionsStyles.subtitleMessageText }>{ text }</Text>
<Text style = { closedCaptionsStyles.subtitleMessageTimestamp }>
{ new Date(timestamp).toLocaleTimeString() }
</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { View, ViewStyle } from 'react-native';
import Avatar from '../../../base/avatar/components/Avatar';
import { ISubtitle } from '../../../subtitles/types';
import SubtitleMessage from './SubtitleMessage';
import { closedCaptionsStyles } from './styles';
interface IProps {
messages: ISubtitle[];
senderId: string;
}
export function SubtitlesGroup({ messages, senderId }: IProps) {
if (!messages.length) {
return null;
}
return (
<View style = { closedCaptionsStyles.subtitlesGroupContainer as ViewStyle }>
<View style = { closedCaptionsStyles.subtitlesGroupAvatar as ViewStyle }>
<Avatar
participantId = { senderId }
size = { 32 } />
</View>
<View style = { closedCaptionsStyles.subtitlesGroupMessagesContainer as ViewStyle }>
{
messages.map((message, index) => (
<SubtitleMessage
key = { `${message.timestamp}-${message.id}` }
showDisplayName = { index === 0 }
{ ...message } />
))
}
</View>
</View>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, View, ViewStyle } from 'react-native';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import { ISubtitle } from '../../../subtitles/types';
import { SubtitlesGroup } from './SubtitlesGroup';
import { closedCaptionsStyles } from './styles';
/**
* The threshold value used to determine if the user is at the bottom of the scroll view.
*/
const SCROLL_THRESHOLD = 50;
interface IProps {
groups: Array<{
messages: ISubtitle[];
senderId: string;
}>;
messages: ISubtitle[];
}
export function SubtitlesMessagesContainer({ messages, groups }: IProps) {
const [ hasNewMessages, setHasNewMessages ] = useState(false);
const [ isScrolledToBottom, setIsScrolledToBottom ] = useState(true);
const scrollViewRef = useRef<ScrollView>(null);
const previousMessages = useRef(messages);
const scrollToBottom = useCallback((withAnimation: boolean) => {
scrollViewRef.current?.scrollToEnd({ animated: withAnimation });
}, []);
const handleNewMessagesClick = useCallback(() => {
scrollToBottom(true);
}, [ scrollToBottom ]);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const isAtBottom = contentOffset.y + layoutMeasurement.height >= contentSize.height - SCROLL_THRESHOLD;
setIsScrolledToBottom(isAtBottom);
if (isAtBottom) {
setHasNewMessages(false);
}
}, []);
useEffect(() => {
scrollToBottom(false);
}, [ scrollToBottom ]);
useEffect(() => {
const newMessages = messages.filter(message => !previousMessages.current.includes(message));
if (newMessages.length > 0) {
if (isScrolledToBottom) {
scrollToBottom(false);
} else {
setHasNewMessages(true);
}
}
previousMessages.current = messages;
}, [ messages, scrollToBottom ]);
return (
<View style = { closedCaptionsStyles.subtitlesMessagesContainer as ViewStyle }>
<ScrollView
contentContainerStyle = { closedCaptionsStyles.subtitlesMessagesList as ViewStyle }
onScroll = { handleScroll }
ref = { scrollViewRef }
scrollEventThrottle = { 16 }>
{
groups.map(group => (
<SubtitlesGroup
key = { `${group.senderId}-${group.messages[0]?.timestamp}` }
messages = { group.messages }
senderId = { group.senderId } />
))
}
</ScrollView>
{
!isScrolledToBottom && hasNewMessages && (
<View style = { closedCaptionsStyles.newMessagesButtonContainer as ViewStyle }>
<Button
accessibilityLabel = 'chat.newMessages'
// eslint-disable-next-line react/jsx-no-bind
icon = { () => (
<Icon
color = { BaseTheme.palette.icon04 }
size = { 20 }
src = { IconArrowDown } />
) }
labelKey = 'chat.newMessages'
onClick = { handleNewMessagesClick }
type = { BUTTON_TYPES.SECONDARY } />
</View>
)
}
</View>
);
}

View File

@@ -14,12 +14,6 @@ const recipientContainer = {
padding: BaseTheme.spacing[2]
};
const inputBar = {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between'
};
/**
* The styles of the feature chat.
*
@@ -44,7 +38,8 @@ export default {
},
emptyComponentText: {
color: BaseTheme.palette.text03,
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center'
},
@@ -113,13 +108,22 @@ export default {
},
emptyComponentWrapper: {
alignSelf: 'center',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: BoxModel.padding,
paddingTop: '8%',
maxWidth: '80%'
},
emptyListStyle: {
flex: 1
},
emptyListContentContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
disabledSendWrapper: {
alignSelf: 'center',
flex: 0,
@@ -133,23 +137,23 @@ export default {
* A special padding to avoid issues on some devices (such as Android devices with custom suggestions bar).
*/
extraBarPadding: {
paddingBottom: 30
paddingBottom: BaseTheme.spacing[8]
},
inputBarNarrow: {
...inputBar,
height: 112,
marginHorizontal: BaseTheme.spacing[3]
inputBar: {
alignSelf: 'stretch',
flexDirection: 'row',
width: '100%'
},
inputBarWide: {
...inputBar,
height: 88,
marginHorizontal: BaseTheme.spacing[9]
sendButton: {
marginRight: BaseTheme.spacing[5],
marginLeft: BaseTheme.spacing[2]
},
customInputContainer: {
width: '75%'
marginLeft: BaseTheme.spacing[5],
flex: 1
},
messageBubble: {
@@ -205,11 +209,6 @@ export default {
fontSize: 13
},
chatContainer: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
tabContainer: {
flexDirection: 'row',
justifyContent: 'center'
@@ -270,3 +269,128 @@ export default {
flex: 1
}
};
/**
* Styles for the ClosedCaptions component.
*/
export const closedCaptionsStyles = {
container: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
emptyContentContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
emptyContent: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
padding: BaseTheme.spacing[3]
},
emptyStateText: {
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center',
maxWidth: '80%'
},
transcribingContainer: {
flex: 1
},
languageButtonContainer: {
justifyContent: 'space-between',
flexDirection: 'row',
padding: BaseTheme.spacing[3]
},
languageButtonText: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginHorizontal: BaseTheme.spacing[2]
},
languageButtonContent: {
flexDirection: 'row'
},
subtitleMessageContainer: {
backgroundColor: BaseTheme.palette.ui02,
borderRadius: BaseTheme.shape.borderRadius,
padding: BaseTheme.spacing[2],
maxWidth: '100%',
marginTop: BaseTheme.spacing[1]
},
subtitleMessageContent: {
maxWidth: '100%',
flex: 1
},
subtitleMessageHeader: {
...BaseTheme.typography.labelBold,
color: BaseTheme.palette.text02,
marginBottom: BaseTheme.spacing[1],
maxWidth: 130
},
subtitleMessageText: {
...BaseTheme.typography.bodyShortRegular,
color: BaseTheme.palette.text01
},
subtitleMessageTimestamp: {
...BaseTheme.typography.labelRegular,
color: BaseTheme.palette.text03,
marginTop: BaseTheme.spacing[1]
},
subtitleMessageInterim: {
opacity: 0.7
},
subtitlesGroupContainer: {
flexDirection: 'row',
marginBottom: BaseTheme.spacing[3]
},
subtitlesGroupAvatar: {
marginBottom: BaseTheme.spacing[10],
marginRight: BaseTheme.spacing[2],
alignSelf: 'flex-start',
width: 32
},
subtitlesGroupMessagesContainer: {
flexDirection: 'column',
flex: 1,
maxWidth: '100%'
},
subtitlesMessagesContainer: {
flex: 1,
position: 'relative',
height: '100%'
},
subtitlesMessagesList: {
padding: BaseTheme.spacing[4]
},
newMessagesButtonContainer: {
position: 'absolute',
bottom: BaseTheme.spacing[3],
alignSelf: 'center'
},
messagesContainer: {
display: 'flex',
flex: 1,
overflow: 'hidden'
}
};

View File

@@ -1,20 +1,13 @@
import React, { useCallback, useMemo, useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import Icon from '../../../base/icons/components/Icon';
import { IconSubtitles } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
import { StartRecordingDialog } from '../../../recording/components/Recording';
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
import LanguageSelector from '../../../subtitles/components/web/LanguageSelector';
import { canStartSubtitles } from '../../../subtitles/functions.any';
import { ISubtitle } from '../../../subtitles/types';
import { isTranscribing } from '../../../transcribing/functions';
// @ts-ignore
import AbstractClosedCaptions, { AbstractProps } from '../AbstractClosedCaptions';
import { SubtitlesMessagesContainer } from './SubtitlesMessagesContainer';
@@ -79,72 +72,19 @@ const useStyles = makeStyles()(theme => {
*
* @returns {JSX.Element} - The ClosedCaptionsTab component.
*/
export default function ClosedCaptionsTab() {
const ClosedCaptionsTab = ({
canStartSubtitles,
filteredSubtitles,
groupedSubtitles,
isButtonPressed,
isTranscribing,
startClosedCaptions
}: AbstractProps): JSX.Element => {
const { classes, theme } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const subtitles = useSelector((state: IReduxState) => state['features/subtitles'].subtitlesHistory);
const language = useSelector((state: IReduxState) => state['features/subtitles']._language);
const selectedLanguage = language?.replace('translation-languages:', '');
const _isTranscribing = useSelector(isTranscribing);
const _canStartSubtitles = useSelector(canStartSubtitles);
const [ isButtonPressed, setButtonPressed ] = useState(false);
const subtitlesError = useSelector((state: IReduxState) => state['features/subtitles']._hasError);
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
const filteredSubtitles = useMemo(() => {
// First, create a map of transcription messages by message ID
const transcriptionMessages = new Map(
subtitles
.filter(s => s.isTranscription)
.map(s => [ s.id, s ])
);
if (!selectedLanguage) {
// When no language is selected, show all original transcriptions
return Array.from(transcriptionMessages.values());
}
// Then, create a map of translation messages by message ID
const translationMessages = new Map(
subtitles
.filter(s => !s.isTranscription && s.language === selectedLanguage)
.map(s => [ s.id, s ])
);
// When a language is selected, for each transcription message:
// 1. Use its translation if available
// 2. Fall back to the original transcription if no translation exists
return Array.from(transcriptionMessages.values())
.filter((m: ISubtitle) => !m.interim)
.map(m => translationMessages.get(m.id) ?? m);
}, [ subtitles, selectedLanguage ]);
const groupedSubtitles = useMemo(() =>
groupMessagesBySender(filteredSubtitles), [ filteredSubtitles ]);
const startClosedCaptions = useCallback(() => {
if (isAsyncTranscriptionEnabled) {
dispatch(openDialog('StartRecordingDialog', StartRecordingDialog, {
recordAudioAndVideo: false
}));
} else {
if (isButtonPressed) {
return;
}
dispatch(setRequestingSubtitles(true, false, null));
setButtonPressed(true);
}
}, [ isAsyncTranscriptionEnabled, dispatch, isButtonPressed, openDialog, setButtonPressed ]);
if (subtitlesError && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
if (!_isTranscribing) {
if (_canStartSubtitles) {
if (!isTranscribing) {
if (canStartSubtitles) {
return (
<div className = { classes.emptyContent }>
<Button
@@ -159,10 +99,6 @@ export default function ClosedCaptionsTab() {
);
}
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
return (
<div className = { classes.emptyContent }>
<Icon
@@ -176,10 +112,6 @@ export default function ClosedCaptionsTab() {
);
}
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
return (
<div className = { classes.container }>
<LanguageSelector />
@@ -190,4 +122,6 @@ export default function ClosedCaptionsTab() {
</div>
</div>
);
}
};
export default AbstractClosedCaptions(ClosedCaptionsTab);

View File

@@ -21,10 +21,9 @@ const titleBarSafeView = {
export default {
bottomContainer: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
bottom: BaseTheme.spacing[8]
display: 'flex',
flexDirection: 'column'
},
/**
@@ -101,7 +100,7 @@ export default {
},
soundDeviceButton: {
marginBottom: BaseTheme.spacing[3],
marginVertical: BaseTheme.spacing[3],
width: 240
},
@@ -162,7 +161,6 @@ export default {
videoStoppedLabel: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginBottom: BaseTheme.spacing[3],
textAlign: 'center',
width: '100%'
},

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

@@ -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

@@ -24,8 +24,6 @@ export default {
flexDirection: 'row',
height: 56,
justifyContent: 'center',
marginBottom: BaseTheme.spacing[0],
paddingBottom: BaseTheme.spacing[4],
width: '100%'
},

View File

@@ -4,7 +4,6 @@ import { WithTranslation } from 'react-i18next';
import {
ActivityIndicator,
FlatList,
SafeAreaView,
TouchableOpacity,
View,
ViewStyle
@@ -513,7 +512,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
*/
_renderShareMeetingButton() {
return (
<SafeAreaView
<View
style = { [
styles.bottomBar as ViewStyle,
this.state.bottomPadding ? styles.extraBarPadding : null
@@ -524,7 +523,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog<IProps, IState> {
src = { IconShare }
style = { styles.shareIcon } />
</TouchableOpacity>
</SafeAreaView>
</View>
);
}

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

@@ -588,7 +588,7 @@ function _registerForNativeEvents(store: IStore) {
}
if (transcription) {
store.dispatch(setRequestingSubtitles(true, false, null));
store.dispatch(setRequestingSubtitles(true, false, null, true));
}
});
@@ -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

@@ -4,7 +4,7 @@ import { StyleProp, Text, TextStyle, View } from 'react-native';
import { navigationStyles } from './styles';
interface ITabBarLabelCounterProps {
activeUnreadNr: boolean;
activeUnreadNr?: boolean;
isFocused: boolean;
label: string;
unreadCount?: number;

View File

@@ -1,62 +0,0 @@
/* eslint-disable lines-around-comment */
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
getClientHeight,
getClientWidth
} from '../../../../../base/modal/components/functions';
import { setFocusedTab } from '../../../../../chat/actions.any';
import Chat from '../../../../../chat/components/native/Chat';
import { ChatTabs } from '../../../../../chat/constants';
import { getFocusedTab } from '../../../../../chat/functions';
import { resetUnreadPollsCount } from '../../../../../polls/actions';
import PollsPane from '../../../../../polls/components/native/PollsPane';
import { screen } from '../../../routes';
import { chatTabBarOptions } from '../../../screenOptions';
const ChatTab = createMaterialTopTabNavigator();
const ChatAndPolls = () => {
const clientHeight = useSelector(getClientHeight);
const clientWidth = useSelector(getClientWidth);
const dispatch = useDispatch();
const currentFocusedTab = useSelector(getFocusedTab);
const initialRouteName = currentFocusedTab === ChatTabs.POLLS
? screen.conference.chatandpolls.tab.polls
: screen.conference.chatandpolls.tab.chat;
return (
// @ts-ignore
<ChatTab.Navigator
backBehavior = 'none'
initialLayout = {{
height: clientHeight,
width: clientWidth
}}
initialRouteName = { initialRouteName }
screenOptions = { chatTabBarOptions }>
<ChatTab.Screen
component = { Chat }
listeners = {{
tabPress: () => {
dispatch(setFocusedTab(ChatTabs.CHAT));
}
}}
name = { screen.conference.chatandpolls.tab.chat } />
<ChatTab.Screen
component = { PollsPane }
listeners = {{
tabPress: () => {
dispatch(setFocusedTab(ChatTabs.POLLS));
dispatch(resetUnreadPollsCount);
}
}}
name = { screen.conference.chatandpolls.tab.polls } />
</ChatTab.Navigator>
);
};
export default ChatAndPolls;

View File

@@ -0,0 +1,88 @@
/* eslint-disable lines-around-comment */
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
getClientHeight,
getClientWidth
} from '../../../../../base/modal/components/functions.native';
import { setFocusedTab } from '../../../../../chat/actions.native';
import Chat from '../../../../../chat/components/native/Chat';
import ClosedCaptions from '../../../../../chat/components/native/ClosedCaptions';
import { ChatTabs } from '../../../../../chat/constants';
import { getFocusedTab, isChatDisabled } from '../../../../../chat/functions';
import { arePollsDisabled } from '../../../../../conference/functions.native';
import { resetUnreadPollsCount } from '../../../../../polls/actions';
import PollsPane from '../../../../../polls/components/native/PollsPane';
import { isCCTabEnabled } from '../../../../../subtitles/functions.any';
import { screen } from '../../../routes';
import { chatTabBarOptions } from '../../../screenOptions';
const ChatTab = createMaterialTopTabNavigator();
const ChatNavigator = () => {
const dispatch = useDispatch();
const clientHeight = useSelector(getClientHeight);
const clientWidth = useSelector(getClientWidth);
const currentFocusedTab = useSelector(getFocusedTab);
const isPollsTabDisabled = useSelector(arePollsDisabled);
const isChatTabDisabled = useSelector(isChatDisabled);
const isCCTabDisabled = !useSelector(isCCTabEnabled);
const initialRouteName
= currentFocusedTab === ChatTabs.POLLS ? screen.conference.chatTabs.tab.polls
: currentFocusedTab === ChatTabs.CLOSED_CAPTIONS ? screen.conference.chatTabs.tab.closedCaptions
: screen.conference.chatTabs.tab.chat;
return (
// @ts-ignore
<ChatTab.Navigator
backBehavior = 'none'
initialLayout = {{
height: clientHeight,
width: clientWidth
}}
initialRouteName = { initialRouteName }
screenOptions = { chatTabBarOptions }>
{
!isChatTabDisabled
&& <ChatTab.Screen
component = { Chat }
listeners = {{
tabPress: () => {
dispatch(setFocusedTab(ChatTabs.CHAT));
}
}}
name = { screen.conference.chatTabs.tab.chat } />
}
{
!isPollsTabDisabled
&& <ChatTab.Screen
component = { PollsPane }
listeners = {{
tabPress: () => {
dispatch(setFocusedTab(ChatTabs.POLLS));
dispatch(resetUnreadPollsCount);
}
}}
name = { screen.conference.chatTabs.tab.polls } />
}
{
!isCCTabDisabled
&& <ChatTab.Screen
component = { ClosedCaptions }
listeners = {{
tabPress: () => {
dispatch(setFocusedTab(ChatTabs.CLOSED_CAPTIONS));
}
}}
name = { screen.conference.chatTabs.tab.closedCaptions } />
}
</ChatTab.Navigator>
);
};
export default ChatNavigator;

View File

@@ -43,6 +43,7 @@ import SpeakerStats
import LanguageSelectorDialog
// @ts-ignore
from '../../../../../subtitles/components/native/LanguageSelectorDialog';
import { isCCTabEnabled } from '../../../../../subtitles/functions.any';
import Whiteboard from '../../../../../whiteboard/components/native/Whiteboard';
// @ts-ignore
import { screen } from '../../../routes';
@@ -68,7 +69,7 @@ import {
// @ts-ignore
} from '../../../screenOptions';
// @ts-ignore
import ChatAndPollsNavigator from '../../chat/components/ChatAndPollsNavigator';
import ChatNavigator from '../../chat/components/ChatNavigator';
// @ts-ignore
import LobbyNavigationContainer from '../../lobby/components/LobbyNavigationContainer';
// @ts-ignore
@@ -84,20 +85,32 @@ const ConferenceStack = createStackNavigator();
const ConferenceNavigationContainer = () => {
const isPollsDisabled = useSelector(arePollsDisabled);
const _isPollsEnabled = !isPollsDisabled;
const _isCCTabEnabled = useSelector(isCCTabEnabled);
const { t } = useTranslation();
let ChatScreen;
let chatScreenName;
let chatTitleString;
let chatTitle;
if (isPollsDisabled) {
ChatScreen = Chat;
chatScreenName = screen.conference.chat;
chatTitleString = 'chat.title';
} else {
ChatScreen = ChatAndPollsNavigator;
chatScreenName = screen.conference.chatandpolls.main;
chatTitleString = 'chat.titleWithPolls';
ChatScreen = ChatNavigator;
chatScreenName = screen.conference.chatTabs.main;
}
if (_isPollsEnabled || _isCCTabEnabled) {
const features = [
_isPollsEnabled ? t('chat.titleWithPolls') : '',
_isCCTabEnabled ? t('chat.titleWithCC') : ''
].filter(Boolean);
chatTitle = `${t('chat.titleWithFeatures')} ${features.join(' and ')}`;
} else {
chatTitle = t('chat.title');
}
const { t } = useTranslation();
return (
<NavigationContainer
@@ -117,7 +130,7 @@ const ConferenceNavigationContainer = () => {
name = { chatScreenName }
options = {{
...chatScreenOptions,
title: t(chatTitleString)
title: chatTitle
}} />
<ConferenceStack.Screen
component = { ParticipantsPane }

View File

@@ -16,6 +16,7 @@ import {
welcomeScreenOptions
} from '../../../screenOptions';
import {
goBack,
settingsNavigationContainerRef
} from '../SettingsNavigationContainerRef';
@@ -51,6 +52,7 @@ const SettingsNavigationContainer = ({ isInWelcomePage }: IProps) => {
const LanguageSelectScreen = useCallback(() =>
(<LanguageSelectView
goBack = { goBack }
isInWelcomePage = { isInWelcomePage } />)
, []);

View File

@@ -3,11 +3,12 @@ export const screen = {
breakoutRooms: 'Breakout Rooms',
carmode: 'Car Mode',
chat: 'Chat',
chatandpolls: {
main: 'Chat and Polls',
chatTabs: {
main: 'Chat',
tab: {
chat: 'Chat',
polls: 'Polls'
polls: 'Polls',
closedCaptions: 'CC'
}
},
container: 'Conference container',

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

@@ -27,7 +27,7 @@ const LobbyParticipantList = () => {
}
return (
<>
<View style = { styles.meetingListContainer }>
<View style = { styles.listDetails as ViewStyle } >
<Text style = { styles.lobbyListDescription as TextStyle }>
{ title }
@@ -50,7 +50,7 @@ const LobbyParticipantList = () => {
participant = { p } />)
)
}
</>
</View>
);
};

View File

@@ -94,8 +94,6 @@ function ParticipantItem({
}: IProps) {
const { t } = useTranslation();
const participantNameContainerStyles
= isKnockingParticipant ? styles.lobbyParticipantNameContainer : styles.participantNameContainer;
return (
<View style = { styles.participantContainer as StyleProp<ViewStyle> } >
@@ -106,12 +104,8 @@ function ParticipantItem({
displayName = { displayName }
participantId = { participantID }
size = { 32 } />
<View
style = { [
styles.participantDetailsContainer,
raisedHand && styles.participantDetailsContainerRaisedHand
] as StyleProp<ViewStyle> }>
<View style = { participantNameContainerStyles as StyleProp<ViewStyle> }>
<View>
<View style = { styles.participantNameContainer as StyleProp<ViewStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.participantName as StyleProp<TextStyle> }>
@@ -128,13 +122,11 @@ function ParticipantItem({
</View>
{
!isKnockingParticipant
&& <>
{ raisedHand && <RaisedHandIndicator /> }
<View style = { styles.participantStatesContainer as StyleProp<ViewStyle> }>
<View style = { styles.participantStateVideo }>{ VideoStateIcons[videoMediaState] }</View>
<View>{ AudioStateIcons[audioMediaState] }</View>
</View>
</>
&& <View style = { styles.participantStatesContainer as StyleProp<ViewStyle> }>
<View>{ raisedHand && <RaisedHandIndicator /> }</View>
<View style = { styles.participantStateVideo }>{ VideoStateIcons[videoMediaState] }</View>
<View>{ AudioStateIcons[audioMediaState] }</View>
</View>
}
</TouchableOpacity>
{ !local && children }

View File

@@ -6,12 +6,8 @@ import BaseTheme from '../../../base/ui/components/BaseTheme.native';
const participantListDescription = {
...BaseTheme.typography.heading6,
color: BaseTheme.palette.text01,
fontSize: 15,
fontWeight: 'bold',
marginLeft: BaseTheme.spacing[2],
marginVertical: BaseTheme.spacing[2],
position: 'relative',
width: '70%'
paddingVertical: BaseTheme.spacing[2]
};
/**
@@ -52,13 +48,6 @@ const contextMenuItem = {
marginLeft: BaseTheme.spacing[3]
};
const participantNameContainer = {
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
paddingLeft: BaseTheme.spacing[3]
};
/**
* The styles of the native components of the feature {@code participants}.
*/
@@ -91,8 +80,7 @@ export default {
flexDirection: 'row',
height: BaseTheme.spacing[9],
paddingLeft: BaseTheme.spacing[3],
paddingRight: BaseTheme.spacing[3],
width: '100%'
paddingRight: BaseTheme.spacing[3]
},
participantContent: {
@@ -106,24 +94,11 @@ export default {
width: '100%'
},
participantDetailsContainer: {
display: 'flex',
flexDirection: 'column',
width: '73%'
},
participantDetailsContainerRaisedHand: {
width: '65%'
},
participantNameContainer: {
...participantNameContainer,
width: '100%'
},
lobbyParticipantNameContainer: {
...participantNameContainer,
width: '40%'
display: 'flex',
flexDirection: 'row',
overflow: 'hidden',
paddingLeft: BaseTheme.spacing[3],
},
participantName: {
@@ -139,19 +114,19 @@ export default {
},
participantStatesContainer: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
marginLeft: 'auto',
width: '15%'
marginLeft: 'auto'
},
participantStateVideo: {
paddingRight: BaseTheme.spacing[3]
marginRight: BaseTheme.spacing[2]
},
raisedHandIndicator: {
backgroundColor: BaseTheme.palette.warning02,
borderRadius: BaseTheme.shape.borderRadius / 2,
borderRadius: BaseTheme.shape.borderRadius,
height: BaseTheme.spacing[4],
width: BaseTheme.spacing[4],
marginLeft: 'auto',
@@ -166,7 +141,7 @@ export default {
buttonAdmit: {
position: 'absolute',
right: 16
right: BaseTheme.spacing[3]
},
buttonReject: {
@@ -201,20 +176,14 @@ export default {
},
participantsPaneFooterContainer: {
alignItems: 'center',
bottom: 0,
height: 128,
left: 0,
paddingHorizontal: BaseTheme.spacing[4],
right: 0
alignSelf: 'stretch',
marginHorizontal: BaseTheme.spacing[5]
},
participantsPaneFooter: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-end',
paddingBottom: BaseTheme.spacing[3],
width: '100%'
marginLeft: 'auto'
},
inviteButton: {

View File

@@ -13,6 +13,17 @@ import { isPiPEnabled } from './external-api.shared';
import logger from './logger';
import { IMediaSessionState } from './types';
/**
* Flag to track if a PiP request is currently pending (requested but not yet entered).
*
* This prevents duplicate PiP entry requests that can occur on macOS when minimizing
* a window. On minimize, both the 'blur' event and 'visibilitychange' event fire in
* rapid succession (within ~10ms), each triggering enterPiP(). Without this guard,
* Electron receives two PiP requests before the first one completes, causing the
* first PiP to immediately exit and triggering a pip leave event that will cause the window to be restored.
*/
let pipRequestPending = false;
/**
* Gets the appropriate video track for PiP based on prejoin state.
* During prejoin, returns local video track. In conference, returns large video participant's track.
@@ -303,6 +314,12 @@ export function requestPictureInPicture() {
// @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
video.requestPictureInPicture().catch((err: Error) => {
logger.error(`Error while requesting PiP after metadata loaded: ${err.message}`);
}).finally(() => {
// Currently Electron will only pass the requests and execute requestPictureInPicture but
// if the code there becomes more complicated it is worth considering to change the implementation
// to handle errors on the Electron side to prevent the scenario where the code in Electron fails
// and the this flag is not reset. This would prevent PiP for ever displaying again.
pipRequestPending = false;
});
}, { once: true });
@@ -312,6 +329,12 @@ export function requestPictureInPicture() {
// @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
video.requestPictureInPicture().catch((err: Error) => {
logger.error(`Error while requesting PiP: ${err.message}`);
}).finally(() => {
// Currently Electron will only pass the requests and execute requestPictureInPicture but
// if the code there becomes more complicated it is worth considering to change the implementation
// to handle errors on the Electron side to prevent the scenario where the code in Electron fails
// and the this flag is not reset. This would prevent PiP for ever displaying again.
pipRequestPending = false;
});
}
@@ -329,6 +352,19 @@ export function enterPiP(videoElement: HTMLVideoElement | undefined | null) {
return;
}
// Check if a PiP request is already pending or PiP is already active.
if (pipRequestPending) {
logger.debug('PiP request already pending, skipping duplicate request');
return;
}
if (document.pictureInPictureElement) {
logger.debug('PiP already active, skipping request');
return;
}
// Check if PiP is supported.
if (!('pictureInPictureEnabled' in document)) {
logger.error('Picture-in-Picture is not supported in this browser');
@@ -348,6 +384,7 @@ export function enterPiP(videoElement: HTMLVideoElement | undefined | null) {
// requestPictureInPicture with userGesture: true in the main process.
if (browser.isElectron()) {
logger.log('Electron detected, sending postMessage to request PiP');
pipRequestPending = true;
APP.API.notifyPictureInPictureRequested();

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlatList, Platform, TextInput, View, ViewStyle } from 'react-native';
import { FlatList, SafeAreaView, TextInput, View, ViewStyle } from 'react-native';
import { Divider } from 'react-native-paper';
import { useDispatch } from 'react-redux';
@@ -97,9 +97,6 @@ const PollCreate = (props: AbstractProps) => {
type = { TERTIARY } />
);
const pollCreateButtonsContainerStyles = Platform.OS === 'android'
? pollsStyles.pollCreateButtonsContainerAndroid : pollsStyles.pollCreateButtonsContainerIos;
/* eslint-disable react/jsx-no-bind */
const renderListItem = ({ index }: { index: number; }) => {
@@ -160,7 +157,7 @@ const PollCreate = (props: AbstractProps) => {
), [ question ]);
return (
<View style = { pollsStyles.pollCreateContainer as ViewStyle }>
<SafeAreaView style = { pollsStyles.pollCreateContainer as ViewStyle }>
<View style = { pollsStyles.pollCreateSubContainer as ViewStyle }>
<FlatList
ListHeaderComponent = { renderListHeaderComponent }
@@ -169,7 +166,7 @@ const PollCreate = (props: AbstractProps) => {
keyExtractor = { (item, index) => index.toString() }
ref = { answerListRef }
renderItem = { renderListItem } />
<View style = { pollCreateButtonsContainerStyles as ViewStyle }>
<View style = { pollsStyles.pollCreateButtonsContainer as ViewStyle }>
<Button
accessibilityLabel = 'polls.create.addOption'
disabled = { answers.length >= ANSWERS_LIMIT }
@@ -207,7 +204,7 @@ const PollCreate = (props: AbstractProps) => {
</View>
</View>
</View>
</View>
</SafeAreaView>
);
};

View File

@@ -38,32 +38,36 @@ const PollsList = ({ setCreateMode }: IPollListProps) => {
scrollToBottom();
}, [ polls ]);
const renderEmptyComponent = useCallback(() => (
<View style = { pollsStyles.noPollContent as ViewStyle }>
<Icon
color = { BaseTheme.palette.icon03 }
size = { 100 }
src = { IconMessage } />
<Text
id = 'no-polls-text'
style = { pollsStyles.noPollText as TextStyle } >
{
t('polls.results.empty')
}
</Text>
</View>
), [ t ]);
const noPolls = listPolls.length === 0;
return (
<>
{
listPolls.length === 0
&& <View style = { pollsStyles.noPollContent as ViewStyle }>
<Icon
color = { BaseTheme.palette.icon03 }
size = { 160 }
src = { IconMessage } />
<Text
id = 'no-polls-text'
style = { pollsStyles.noPollText as TextStyle } >
{
t('polls.results.empty')
}
</Text>
</View>
}
<FlatList
data = { listPolls }
extraData = { listPolls }
// eslint-disable-next-line react/jsx-no-bind
keyExtractor = { (item, index) => index.toString() }
ref = { flatlistRef }
renderItem = { renderItem } />
</>
<FlatList
ListEmptyComponent = { renderEmptyComponent }
// @ts-ignore
contentContainerStyle = { noPolls && pollsStyles.emptyListContentContainer as ViewStyle }
data = { listPolls }
extraData = { listPolls }
// eslint-disable-next-line react/jsx-no-bind
keyExtractor = { (item, index) => index.toString() }
ref = { flatlistRef }
renderItem = { renderItem }
style = { noPolls && pollsStyles.emptyListStyle as ViewStyle } />
);
};

View File

@@ -1,6 +1,5 @@
import { useNavigation } from '@react-navigation/native';
import React, { useEffect } from 'react';
import { Platform } from 'react-native';
import React, { useCallback, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
@@ -42,28 +41,27 @@ const PollsPane = (props: AbstractProps) => {
}, [ isPollsTabFocused, unreadPollsCount ]);
const createPollButtonStyles = Platform.OS === 'android'
? pollsStyles.createPollButtonAndroid : pollsStyles.createPollButtonIos;
const renderCreateAPollBtn = useCallback(() => (
!createMode && !isCreatePollsDisabled && <Button
accessibilityLabel = 'polls.create.create'
id = { t('polls.create.create') }
labelKey = 'polls.create.create'
onClick = { onCreate }
style = { pollsStyles.createPollButton }
type = { BUTTON_TYPES.PRIMARY } />
), [ createMode, isCreatePollsDisabled ]);
return (
<JitsiScreen
contentContainerStyle = { pollsStyles.pollPane as StyleType }
disableForcedKeyboardDismiss = { true }
footerComponent = { renderCreateAPollBtn }
hasExtraHeaderHeight = { true }
style = { pollsStyles.pollPaneContainer as StyleType }>
{
createMode
? <PollCreate setCreateMode = { setCreateMode } />
: <>
<PollsList setCreateMode = { setCreateMode } />
{!isCreatePollsDisabled && <Button
accessibilityLabel = 'polls.create.create'
id = { t('polls.create.create') }
labelKey = 'polls.create.create'
onClick = { onCreate }
style = { createPollButtonStyles }
type = { BUTTON_TYPES.PRIMARY } />}
</>
: <PollsList setCreateMode = { setCreateMode } />
}
</JitsiScreen>
);

View File

@@ -117,21 +117,26 @@ export const resultsStyles = createStyleSheet({
export const pollsStyles = createStyleSheet({
emptyListStyle: {
flex: 1
},
emptyListContentContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center'
},
noPollContent: {
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
top: '25%'
justifyContent: 'center'
},
noPollText: {
flex: 1,
color: BaseTheme.palette.text03,
...BaseTheme.typography.bodyLongBold,
color: BaseTheme.palette.text02,
textAlign: 'center',
maxWidth: '70%'
maxWidth: '80%'
},
pollItemContainer: {
@@ -153,13 +158,7 @@ export const pollsStyles = createStyleSheet({
marginTop: BaseTheme.spacing[3]
},
pollCreateButtonsContainerAndroid: {
marginBottom: BaseTheme.spacing[8],
marginHorizontal: BaseTheme.spacing[3]
},
pollCreateButtonsContainerIos: {
marginBottom: BaseTheme.spacing[5],
pollCreateButtonsContainer: {
marginHorizontal: BaseTheme.spacing[3]
},
@@ -205,14 +204,8 @@ export const pollsStyles = createStyleSheet({
color: BaseTheme.palette.action01
},
createPollButtonIos: {
marginHorizontal: 20,
marginVertical: BaseTheme.spacing[5]
},
createPollButtonAndroid: {
marginHorizontal: 20,
marginVertical: BaseTheme.spacing[5]
createPollButton: {
marginHorizontal: BaseTheme.spacing[5],
},
pollPane: {

View File

@@ -52,7 +52,8 @@ export default class PrejoinApp extends BaseApp<Props> {
dispatch?.(setConfig({
prejoinConfig: {
enabled: true
enabled: true,
showHangUp: false
},
startWithAudioMuted,
startWithVideoMuted

View File

@@ -1,6 +1,5 @@
import React, { ComponentType, PureComponent } from 'react';
import { TouchableWithoutFeedback } from 'react-native';
import { Edge, SafeAreaView } from 'react-native-safe-area-context';
import { TouchableWithoutFeedback, View } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
@@ -9,7 +8,6 @@ import { hideDialog } from '../../../base/dialog/actions';
import { isDialogOpen } from '../../../base/dialog/functions';
import { getParticipantCount } from '../../../base/participants/functions';
import { StyleType } from '../../../base/styles/functions.native';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import ReactionMenu from './ReactionMenu';
@@ -38,11 +36,6 @@ interface IProps {
*/
_styles: StyleType;
/**
* The indicator which determines whether the Toolbox is visible.
*/
_toolboxVisible: boolean;
/**
* The width of the screen.
*/
@@ -87,13 +80,12 @@ class ReactionMenuDialog extends PureComponent<IProps> {
* @returns {ReactElement}
*/
override render() {
const { _height, _participantCount, _styles, _toolboxVisible, _width } = this.props;
const { _height, _participantCount, _styles, _width } = this.props;
return (
<TouchableWithoutFeedback
onPress = { this._onCancel }>
<SafeAreaView
edges = { [ 'bottom', 'left', 'right', !_toolboxVisible && 'top' ].filter(Boolean) as Edge[] }
<View
style = { [
_styles,
{
@@ -103,7 +95,7 @@ class ReactionMenuDialog extends PureComponent<IProps> {
<ReactionMenu
onCancel = { this._onCancel }
overflowMenu = { false } />
</SafeAreaView>
</View>
</TouchableWithoutFeedback>
);
}
@@ -138,8 +130,7 @@ function _mapStateToProps(state: IReduxState) {
_styles: ColorSchemeRegistry.get(state, 'Toolbox').reactionDialog,
_width: state['features/base/responsive-ui'].clientWidth,
_height: state['features/base/responsive-ui'].clientHeight,
_participantCount: getParticipantCount(state),
_toolboxVisible: isToolboxVisible(state)
_participantCount: getParticipantCount(state)
};
}

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

@@ -415,7 +415,7 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
if (this.state.selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
&& this.state.shouldRecordTranscription) {
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage));
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage, true));
} else {
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: this.state.shouldRecordTranscription

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

@@ -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

@@ -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

@@ -1,7 +1,7 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { GestureResponderEvent, ScrollView, Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { Edge } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
@@ -11,12 +11,13 @@ import { IconArrowLeft } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import BaseThemeNative from '../../../base/ui/components/BaseTheme.native';
import HeaderNavigationButton from '../../../mobile/navigation/components/HeaderNavigationButton';
import { goBack, navigate } from '../../../mobile/navigation/components/settings/SettingsNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import styles from './styles';
const LanguageSelectView = ({ isInWelcomePage }: { isInWelcomePage?: boolean; }) => {
const LanguageSelectView = ({ goBack, isInWelcomePage }: {
goBack?: (e?: GestureResponderEvent | React.MouseEvent) => void;
isInWelcomePage?: boolean;
}) => {
const { t } = useTranslation();
const navigation = useNavigation();
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
@@ -25,7 +26,7 @@ const LanguageSelectView = ({ isInWelcomePage }: { isInWelcomePage?: boolean; })
const setLanguage = useCallback(language => () => {
i18next.changeLanguage(language);
conference?.setTranscriptionLanguage(language);
navigate(screen.settings.main);
goBack?.();
}, [ conference, i18next ]);
const headerLeft = () => (

View File

@@ -83,7 +83,7 @@ export function toggleRequestingSubtitles() {
* @param {boolean} enabled - The new state of the subtitles.
* @param {boolean} displaySubtitles - Whether to display subtitles or not.
* @param {string} language - The language of the subtitles.
* @param {boolean} backendRecordingOn - Whether backend recording is on.
* @param {boolean} forceBackendRecordingOn - Whether to force that backend recording is on.
* @returns {{
* type: SET_REQUESTING_SUBTITLES,
* backendRecordingOn: boolean,
@@ -95,11 +95,13 @@ export function toggleRequestingSubtitles() {
export function setRequestingSubtitles(
enabled: boolean,
displaySubtitles = true,
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`) {
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`,
forceBackendRecordingOn: boolean = false) {
return {
type: SET_REQUESTING_SUBTITLES,
displaySubtitles,
enabled,
forceBackendRecordingOn,
language
};
}

View File

@@ -39,7 +39,6 @@ const useStyles = makeStyles()(theme => {
* Uses the same language options as LanguageSelectorDialog and
* updates the subtitles language preference in Redux.
*
* @param {IProps} props - The component props.
* @returns {JSX.Element} - The rendered component.
*/
function LanguageSelector() {
@@ -51,6 +50,13 @@ function LanguageSelector() {
state,
selectedLanguage?.replace('translation-languages:', '')
));
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
// Hide the "Translate to" option when asyncTranscription is enabled
if (isAsyncTranscriptionEnabled) {
return null;
}
/**
* Maps available languages to Select component options format.

View File

@@ -98,7 +98,7 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case SET_REQUESTING_SUBTITLES:
_requestingSubtitlesChange(store, action.enabled, action.language);
_requestingSubtitlesChange(store, action.enabled, action.language, action.forceBackendRecordingOn);
break;
}
@@ -344,13 +344,16 @@ function _getPrimaryLanguageCode(language: string) {
* @param {Store} store - The redux store.
* @param {boolean} enabled - Whether subtitles should be enabled or not.
* @param {string} language - The language to use for translation.
* @param {boolean} forceBackendRecordingOn - Whether to force backend recording is on or not. This is used only when
* we start recording, stopping is based on whether isTranscribingEnabled is already set.
* @private
* @returns {void}
*/
function _requestingSubtitlesChange(
{ dispatch, getState }: IStore,
enabled: boolean,
language?: string | null) {
language?: string | null,
forceBackendRecordingOn: boolean = false) {
const state = getState();
const { conference } = state['features/base/conference'];
const backendRecordingOn = conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription;
@@ -375,7 +378,9 @@ function _requestingSubtitlesChange(
}));
dispatch(setSubtitlesError(true));
});
} else {
}
if (backendRecordingOn || forceBackendRecordingOn) {
conference?.getMetadataHandler()?.setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
@@ -388,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

@@ -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

@@ -76,3 +76,13 @@ local function anonymous(self, message)
end
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);
module:hook("pre-resource-unbind", function (e)
local error, session = e.error, e.session;
prosody.events.fire_event('jitsi-pre-session-unbind', {
jid = session.full_jid,
session = session,
error = error
});
end, 11);

View File

@@ -61,6 +61,16 @@ end
module:hook_global("bosh-session", init_session);
module:hook_global("websocket-session", init_session);
module:hook("pre-resource-unbind", function (e)
local error, session = e.error, e.session;
prosody.events.fire_event('jitsi-pre-session-unbind', {
jid = session.full_jid,
session = session,
error = error
});
end, 11);
function provider.test_password(username, password)
return nil, "Password based auth not supported";
end

View File

@@ -11,8 +11,13 @@ local json = require "cjson.safe";
local http = require "net.http";
local inspect = require 'inspect';
local util = module:require 'util';
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_vpaas = util.is_vpaas;
local ban_check_count = module:measure("muc_auth_ban_check", "rate")
local ban_check_users_banned_count = module:measure("muc_auth_ban_users_banned", "rate")
local ban_check_error_count = module:measure("muc_auth_ban_check_error", "rate")
-- we will cache banned tokens to avoid extra requests
-- on destroying session, websocket retries 2 more times before giving up
@@ -38,14 +43,20 @@ end);
local function shouldAllow(session)
local token = session.auth_token;
if token ~= nil then
-- module:log("debug", "Checking whether user should be banned ")
if token ~= nil and session.jitsi_web_query_room and session.jitsi_web_query_prefix then
-- cached tokens are banned
if cache:get(token) then
return false;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
return nil;
end
if not is_vpaas(room) then
return true;
end
-- TODO: do this only for enabled customers
ban_check_count();
local function cb(content, code, response, request)
@@ -54,7 +65,7 @@ local function shouldAllow(session)
local r = json.decode(content)
if r['access'] ~= nil and r['access'] == false then
module:log("info", "User is banned room:%s tenant:%s user_id:%s group:%s",
session.jitsi_meet_room, session.jitsi_web_query_prefix,
session.jitsi_web_query_room, session.jitsi_web_query_prefix,
inspect(session.jitsi_meet_context_user), session.jitsi_meet_context_group);
ban_check_users_banned_count();
@@ -68,6 +79,11 @@ local function shouldAllow(session)
cache:set(token, socket.gettime());
end
else
ban_check_error_count();
module:log("warn", "Error code:%s contacting url:%s content:%s room:%s tenant:%s response:%s request:%s",
code, ACCESS_MANAGER_URL, session.jitsi_web_query_room, session.jitsi_web_query_prefix,
inspect(response), inspect(request), content);
end
end

Some files were not shown because too many files have changed in this diff Show More