Compare commits

...

37 Commits

Author SHA1 Message Date
Hristo Terezov
3250686ce9 fix(visitors): gum pending on promotion. 2024-03-25 09:57:00 -05:00
Hristo Terezov
8149dfecd0 fix(kick): JS error when participant pane is open.
There are cases when if you are kicked and the participant pane is
open, the getBreakoutRooms() call will return undefined and since
isBreakoutRoomRenameAllowed is used in useSelector and fails, all
execution will stop leaving us in a broken state.
2024-03-18 17:22:04 -05:00
Saúl Ibarra Corretgé
412f462448 feat(recording) add ability to change recording defaults
If recordings.recordAudioAndVideo is set to false don't record
audio-video by default.
2024-03-18 17:08:26 +01:00
Mihaela Dumitru
71b4943480 fix(recordings) disable default auto transcribe (#14495) 2024-03-18 16:59:12 +02:00
Mihaela Dumitru
9b3931b575 fix(whiteboard) adjust whiteboard ready check to work without config (#14486) 2024-03-18 16:59:12 +02:00
Saúl Ibarra Corretgé
9d7a4c7dea fix(recording) restore labels when recording locally 2024-02-27 10:10:24 -06:00
Saúl Ibarra Corretgé
5fc0ecf55e fix(recording) remove duplicated check 2024-02-27 10:09:44 -06:00
Saúl Ibarra Corretgé
15f935e83c fix(subtitles) keep subtitles state when recording 2024-02-27 10:09:31 -06:00
Hristo Terezov
9de8f612a1 feat(recorder-transcription): Handle correctly in the UI.
Until this commit we didn't make difference between transcriptions from the recording dialog and subtitles. Now subtitles are not considered recording anymore and only the transcriptions started from recording dialog are considered recording.
2024-02-27 10:09:01 -06:00
Saúl Ibarra Corretgé
a01b9655b5 fix(recording) set transcribing metadata when stopping 2024-02-26 13:34:45 +01:00
Hristo Terezov
6cc2a30ffa fix(overflow-menu): disabled buttons styles 2024-02-23 16:58:44 -06:00
Hristo Terezov
6f3706f734 fix(recording/livestreaming): labels
Display only livestreaming label when the livestreaming and the transcribtions are on.
2024-02-23 16:23:09 -06:00
Hristo Terezov
3e03257b3c fix(Livestreaming): label 2024-02-22 16:52:17 -06:00
Mihaela Dumitru
d8df1a2393 fix(ui) center conference info (#14379) 2024-02-16 08:05:28 -06:00
Saúl Ibarra Corretgé
2296a50854 fix(local-recordings) fix on Chrome
WebM + VP8 seems to be the one thing that works, requesting H.264 starts
the recorder in Matroska + AV1 format (WAT) but no data is received at
all.
2024-02-15 18:27:41 +01:00
Saúl Ibarra Corretgé
9d90045918 fix(recording) only show advanced section for cloud recordings 2024-02-15 18:27:19 +01:00
Saúl Ibarra Corretgé
51cbf7272a fix(recording) fix checking if we can skip the recording dialog 2024-02-15 17:14:10 +01:00
Mihaela Dumitru
22809437e0 fix(prejoin) adjust recording warning (#14361) (#14362) 2024-02-13 13:45:23 +02:00
damencho
153dd4679c fix: Fixes polls parsing. 2024-02-12 13:23:06 -06:00
Saúl Ibarra Corretgé
f909c101da fix(whiteboard) handle metadata being null 2024-02-12 13:18:18 +01:00
Jaya Allamsetty
4a2c27bf64 chore(deps): Update lib-jitsi-meet, fixes for VP8 simulcast issue. 2024-02-09 11:14:56 -05:00
bgrozev
fba2abb772 feat: Notify jicofo when room_metadata changes the config form. (#14346) 2024-02-09 11:15:29 +01:00
Avram Tudor
86d68976bc feat(recordings) send recordings metadata
* ref: centralise conference metadata updates

* feat(recordings) send recordings metadata
2024-02-09 11:15:00 +01:00
Mihaela Dumitru
738581b2c8 feat(whiteboard) add native implementation (#14327) 2024-02-09 11:14:55 +01:00
Saúl Ibarra Corretgé
bb1933f892 fix(ci) use the right CocoaPods version 2024-02-09 11:12:05 +01:00
Mihaela Dumitru
e2d305aea9 feat(prejoin) display recording warning (#14347) 2024-02-09 11:11:14 +01:00
Avram Tudor
622043ff7e code review 2024-02-08 18:02:35 +02:00
Avram Tudor
d50163deee fix(recording) ensure recording label is shown when transcription is on 2024-02-08 18:02:27 +02:00
Avram Tudor
48555180d8 fix(recording) do not show advanced options for users with local recording rights 2024-02-08 18:02:20 +02:00
Avram Tudor
892e40bdca fix(recording) do not show stop recording option in some wrong cases 2024-02-08 18:02:15 +02:00
Avram Tudor
c8e300a12f ref: add selector to check if meeting is transcribed 2024-02-08 18:02:09 +02:00
Avram Tudor
ffc02b2b3e ref: add selector for checking cloud recording running status 2024-02-08 18:02:03 +02:00
Avram Tudor
28d6046cf4 fix(recording) fix recording suggestion not being shown in some cases (#14333)
Initial implementation did not account for cases where participants become moderators
2024-02-07 09:28:14 -06:00
Saúl Ibarra Corretgé
3d69907db2 fix(recording) avoid having duplicate notification sounds 2024-02-06 14:37:23 -06:00
Saúl Ibarra Corretgé
a559de5ed1 fix(recording) fix default value to auto-transcribe-on-record 2024-02-06 14:37:13 -06:00
Saúl Ibarra Corretgé
e7712fa35a fix(conference) avoid adding extra endpoint message listeners 2024-02-06 14:36:39 -06:00
Saúl Ibarra Corretgé
643a6dc130 fix(conference) move endpointMessageReceived action
It's used for a myrias of things, not just subtitles.
2024-02-06 14:36:18 -06:00
99 changed files with 1858 additions and 697 deletions

View File

@@ -73,6 +73,10 @@ jobs:
node-version: 16
cache: 'npm'
- run: npm install
- name: setup-cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
podfile-path: ios/Podfile.lock
- name: Install Pods
run: |
pod --version

View File

@@ -38,6 +38,7 @@ import {
dataChannelClosed,
dataChannelOpened,
e2eRttChanged,
endpointMessageReceived,
kickedOut,
lockStateChanged,
nonParticipantMessageReceived,
@@ -162,10 +163,8 @@ import { isScreenAudioShared } from './react/features/screen-share/functions';
import { toggleScreenshotCaptureSummary } from './react/features/screenshot-capture/actions';
import { AudioMixerEffect } from './react/features/stream-effects/audio-mixer/AudioMixerEffect';
import { createRnnoiseProcessor } from './react/features/stream-effects/rnnoise';
import { endpointMessageReceived } from './react/features/subtitles/actions.any';
import { handleToggleVideoMuted } from './react/features/toolbox/actions.any';
import { muteLocal } from './react/features/video-menu/actions.any';
import { iAmVisitor } from './react/features/visitors/functions';
import UIEvents from './service/UI/UIEvents';
const logger = Logger.getLogger(__filename);
@@ -697,10 +696,6 @@ export default {
const handleInitialTracks = (options, tracks) => {
let localTracks = tracks;
// No local tracks are added when user joins as a visitor.
if (iAmVisitor(state)) {
return [];
}
if (options.startWithAudioMuted || room?.isStartAudioMuted()) {
// Always add the track on Safari because of a known issue where audio playout doesn't happen
// if the user joins audio and video muted, i.e., if there is no local media capture.
@@ -1824,28 +1819,24 @@ export default {
room.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(...args) => {
APP.store.dispatch(endpointMessageReceived(...args));
if (args && args.length >= 2) {
const [ sender, eventData ] = args;
if (eventData.name === ENDPOINT_TEXT_MESSAGE_NAME) {
APP.API.notifyEndpointTextMessageReceived({
senderInfo: {
jid: sender._jid,
id: sender._id
},
eventData
});
}
(participant, data) => {
APP.store.dispatch(endpointMessageReceived(participant, data));
if (data?.name === ENDPOINT_TEXT_MESSAGE_NAME) {
APP.API.notifyEndpointTextMessageReceived({
senderInfo: {
jid: participant.getJid(),
id: participant.getId()
},
eventData: data
});
}
});
room.on(
JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
(...args) => {
APP.store.dispatch(nonParticipantMessageReceived(...args));
APP.API.notifyNonParticipantMessageReceived(...args);
(id, data) => {
APP.store.dispatch(nonParticipantMessageReceived(id, data));
APP.API.notifyNonParticipantMessageReceived(id, data);
});
room.on(

View File

@@ -326,9 +326,14 @@ var config = {
// configuration for all things recording related. Existing settings will be migrated here in the future.
// recordings: {
// // IF true (default) recording audio and video is selected by default in the recording dialog.
// // recordAudioAndVideo: true,
// // If true, shows a notification at the start of the meeting with a call to action button
// // to start recording (for users who can do so).
// // suggestRecording: true,
// // If true, shows a warning label in the prejoin screen to point out the possibility that
// // the call you're joining might be recorded.
// // showPrejoinWarning: true,
// },
// recordingService: {

View File

@@ -41,7 +41,6 @@
position: absolute;
top: 0;
height: 48px;
max-width: calc(100% - 24px);
}
@keyframes hideSubject {

View File

@@ -36,6 +36,7 @@ $flagsImagePath: "../images/";
@import 'modals/invite/info';
@import 'modals/screen-share/share-audio';
@import 'modals/screen-share/share-screen-warning';
@import 'modals/whiteboard';
@import 'videolayout_default';
@import 'subject';
@import 'popup_menu';

View File

@@ -0,0 +1,7 @@
.whiteboard {
.excalidraw-wrapper {
height: 100vh;
width: 100vw;
}
}

1
globals.d.ts vendored
View File

@@ -21,6 +21,7 @@ declare global {
JitsiMeetElectron?: any;
PressureObserver?: any;
PressureRecord?: any;
ReactNativeWebView?: any;
// selenium tests handler
_sharedVideoPlayer: any;
alwaysOnTop: { api: any };

3
globals.native.d.ts vendored
View File

@@ -19,6 +19,9 @@ interface IWindow {
location: ILocation;
PressureObserver?: any;
PressureRecord?: any;
ReactNativeWebView?: any;
TextDecoder?: any;
TextEncoder?: any;
self: any;
top: any;

View File

@@ -560,6 +560,7 @@
"noNumbers": "No dial-in numbers.",
"noPassword": "None",
"noRoom": "No room was specified to dial-in into.",
"noWhiteboard": "Could not load the whiteboard.",
"numbers": "Dial-in Numbers",
"password": "$t(lockRoomPasswordUppercase): ",
"reachedLimit": "You have reached the limit of your plan.",
@@ -567,7 +568,8 @@
"sipAudioOnly": "SIP audio only address",
"title": "Share",
"tooltip": "Share link and dial-in info for this meeting",
"upgradeOptions": "Please check the upgrade options on"
"upgradeOptions": "Please check the upgrade options on",
"whiteboardError": "Error loading the whiteboard. Please try again later."
},
"inlineDialogFailure": {
"msg": "We stumbled a bit.",
@@ -937,6 +939,7 @@
"or": "or",
"premeeting": "Pre meeting",
"proceedAnyway": "Proceed anyway",
"recordingWarning": "Other participants may be recording this call",
"screenSharingError": "Screen sharing error:",
"showScreen": "Enable pre meeting screen",
"startWithPhone": "Start with phone audio",
@@ -1530,6 +1533,7 @@
"whiteboard": {
"accessibilityLabel": {
"heading": "Whiteboard"
}
},
"screenTitle": "Whiteboard"
}
}

View File

@@ -113,7 +113,7 @@ import { isAudioMuteButtonDisabled } from '../../react/features/toolbox/function
import { setTileView, toggleTileView } from '../../react/features/video-layout/actions.any';
import { muteAllParticipants } from '../../react/features/video-menu/actions';
import { setVideoQuality } from '../../react/features/video-quality/actions';
import { toggleWhiteboard } from '../../react/features/whiteboard/actions.any';
import { toggleWhiteboard } from '../../react/features/whiteboard/actions.web';
import { getJitsiMeetTransport } from '../transport';
import {

21
package-lock.json generated
View File

@@ -60,7 +60,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/v1767.0.0+178f9bbf/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-7790",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -110,6 +110,7 @@
"redux-thunk": "2.4.1",
"seamless-scroll-polyfill": "2.1.8",
"semver": "7.5.4",
"text-encoding": "0.7.0",
"tss-react": "4.4.4",
"util": "0.12.1",
"uuid": "8.3.2",
@@ -12904,8 +12905,7 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1767.0.0+178f9bbf/lib-jitsi-meet.tgz",
"integrity": "sha512-fhuRtG6n1EqfcAMwXF2BMfa5pBO4lHgWRoWj4tviY8//eTrld98HbWxMkSXKUtL0B9slXlPQYn+WyjYUJ63wuQ==",
"resolved": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#e5571260d780d196eec489d61e6b2d241db3d60c",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -18625,6 +18625,12 @@
"node": ">=0.10.0"
}
},
"node_modules/text-encoding": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==",
"deprecated": "no longer maintained"
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -29472,8 +29478,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1767.0.0+178f9bbf/lib-jitsi-meet.tgz",
"integrity": "sha512-fhuRtG6n1EqfcAMwXF2BMfa5pBO4lHgWRoWj4tviY8//eTrld98HbWxMkSXKUtL0B9slXlPQYn+WyjYUJ63wuQ==",
"version": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#e5571260d780d196eec489d61e6b2d241db3d60c",
"from": "lib-jitsi-meet@https://github.com/jitsi/lib-jitsi-meet#release-7790",
"requires": {
"@jitsi/js-utils": "2.2.1",
"@jitsi/logger": "2.0.2",
@@ -33629,6 +33635,11 @@
}
}
},
"text-encoding": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz",
"integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA=="
},
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",

View File

@@ -66,7 +66,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1767.0.0+178f9bbf/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-7790",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -116,6 +116,7 @@
"redux-thunk": "2.4.1",
"seamless-scroll-polyfill": "2.1.8",
"semver": "7.5.4",
"text-encoding": "0.7.0",
"tss-react": "4.4.4",
"util": "0.12.1",
"uuid": "8.3.2",

View File

@@ -51,5 +51,6 @@ import '../video-layout/middleware';
import '../video-quality/middleware';
import '../videosipgw/middleware';
import '../visitors/middleware';
import '../whiteboard/middleware.any';
import './middleware';

View File

@@ -13,5 +13,6 @@ import '../mobile/react-native-sdk/middleware';
import '../mobile/watchos/middleware';
import '../share-room/middleware';
import '../shared-video/middleware';
import '../whiteboard/middleware.native';
import './middlewares.any';

View File

@@ -22,6 +22,6 @@ import '../talk-while-muted/middleware';
import '../toolbox/middleware';
import '../face-landmarks/middleware';
import '../gifs/middleware';
import '../whiteboard/middleware';
import '../whiteboard/middleware.web';
import './middlewares.any';

View File

@@ -55,3 +55,4 @@ import '../video-layout/reducer';
import '../video-quality/reducer';
import '../videosipgw/reducer';
import '../visitors/reducer';
import '../whiteboard/reducer';

View File

@@ -16,7 +16,6 @@ import '../noise-suppression/reducer';
import '../screenshot-capture/reducer';
import '../talk-while-muted/reducer';
import '../virtual-background/reducer';
import '../whiteboard/reducer';
import '../web-hid/reducer';
import './reducers.any';

View File

@@ -178,6 +178,18 @@ export const DATA_CHANNEL_OPENED = 'DATA_CHANNEL_OPENED';
*/
export const DATA_CHANNEL_CLOSED = 'DATA_CHANNEL_CLOSED';
/**
* The type of (redux) action which indicates that an endpoint message
* sent by another participant to the data channel is received.
*
* {
* type: ENDPOINT_MESSAGE_RECEIVED,
* participant: Object,
* data: Object
* }
*/
export const ENDPOINT_MESSAGE_RECEIVED = 'ENDPOINT_MESSAGE_RECEIVED';
/**
* The type of action which signals that the user has been kicked out from
* the conference.
@@ -333,3 +345,13 @@ export const SET_START_MUTED_POLICY = 'SET_START_MUTED_POLICY';
* }
*/
export const SET_ASSUMED_BANDWIDTH_BPS = 'SET_ASSUMED_BANDWIDTH_BPS';
/**
* The type of (redux) action which updated the conference metadata.
*
* {
* type: UPDATE_CONFERENCE_METADATA,
* metadata: Object
* }
*/
export const UPDATE_CONFERENCE_METADATA = 'UPDATE_CONFERENCE_METADATA';

View File

@@ -1,7 +1,6 @@
import { createStartMutedConfigurationEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IReduxState, IStore } from '../../app/types';
import { endpointMessageReceived } from '../../subtitles/actions.any';
import { setIAmVisitor } from '../../visitors/actions';
import { iAmVisitor } from '../../visitors/functions';
import { overwriteConfig } from '../config/actions';
@@ -9,8 +8,15 @@ import { getReplaceParticipant } from '../config/functions';
import { connect, disconnect, hangup } from '../connection/actions';
import { JITSI_CONNECTION_CONFERENCE_KEY } from '../connection/constants';
import { JitsiConferenceEvents, JitsiE2ePingEvents } from '../lib-jitsi-meet';
import { setAudioMuted, setAudioUnmutePermissions, setVideoMuted, setVideoUnmutePermissions } from '../media/actions';
import {
gumPending,
setAudioMuted,
setAudioUnmutePermissions,
setVideoMuted,
setVideoUnmutePermissions
} from '../media/actions';
import { MEDIA_TYPE } from '../media/constants';
import { IGUMPendingState } from '../media/types';
import {
dominantSpeakerChanged,
participantKicked,
@@ -48,6 +54,7 @@ import {
DATA_CHANNEL_CLOSED,
DATA_CHANNEL_OPENED,
E2E_RTT_CHANGED,
ENDPOINT_MESSAGE_RECEIVED,
KICKED_OUT,
LOCK_STATE_CHANGED,
NON_PARTICIPANT_MESSAGE_RECEIVED,
@@ -61,7 +68,8 @@ import {
SET_PENDING_SUBJECT_CHANGE,
SET_ROOM,
SET_START_MUTED_POLICY,
SET_START_REACTIONS_MUTED
SET_START_REACTIONS_MUTED,
UPDATE_CONFERENCE_METADATA
} from './actionTypes';
import {
AVATAR_URL_COMMAND,
@@ -79,7 +87,7 @@ import {
sendLocalParticipant
} from './functions';
import logger from './logger';
import { IJitsiConference } from './reducer';
import { IConferenceMetadata, IJitsiConference } from './reducer';
/**
* Adds conference (event) listeners.
@@ -275,6 +283,21 @@ function _addConferenceListeners(conference: IJitsiConference, dispatch: IStore[
})));
}
/**
* Action for updating the conference metadata.
*
* @param {IConferenceMetadata} metadata - The metadata object.
* @returns {{
* type: UPDATE_CONFERENCE_METADATA,
* metadata: IConferenceMetadata
* }}
*/
export function updateConferenceMetadata(metadata: IConferenceMetadata | null) {
return {
type: UPDATE_CONFERENCE_METADATA,
metadata
};
}
/**
* Create an action for when the end-to-end RTT against a specific remote participant has changed.
@@ -630,6 +653,25 @@ export function dataChannelClosed(code: number, reason: string) {
};
}
/**
* Signals that a participant sent an endpoint message on the data channel.
*
* @param {Object} participant - The participant details sending the message.
* @param {Object} data - The data carried by the endpoint message.
* @returns {{
* type: ENDPOINT_MESSAGE_RECEIVED,
* participant: Object,
* data: Object
* }}
*/
export function endpointMessageReceived(participant: Object, data: Object) {
return {
type: ENDPOINT_MESSAGE_RECEIVED,
participant,
data
};
}
/**
* Action to end a conference for all participants.
*
@@ -1017,6 +1059,11 @@ export function redirect(vnode: string, focusJid: string, username: string) {
.then(() => dispatch(conferenceWillInit()))
.then(() => dispatch(connect()))
.then(() => {
// Clear the gum pending state in case we have set it to pending since we are starting the
// conference without tracks.
dispatch(gumPending([ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ], IGUMPendingState.NONE));
// FIXME: Workaround for the web version. To be removed once we get rid of conference.js
if (typeof APP !== 'undefined') {
APP.conference.startConference([]);

View File

@@ -25,7 +25,7 @@ import { overwriteConfig } from '../config/actions';
import { CONNECTION_ESTABLISHED, CONNECTION_FAILED } from '../connection/actionTypes';
import { connect, connectionDisconnected, disconnect } from '../connection/actions';
import { validateJwt } from '../jwt/functions';
import { JitsiConferenceErrors, JitsiConnectionErrors } from '../lib-jitsi-meet';
import { JitsiConferenceErrors, JitsiConferenceEvents, JitsiConnectionErrors } from '../lib-jitsi-meet';
import { PARTICIPANT_UPDATED, PIN_PARTICIPANT } from '../participants/actionTypes';
import { PARTICIPANT_ROLE } from '../participants/constants';
import {
@@ -34,6 +34,7 @@ import {
getPinnedParticipant
} from '../participants/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { TRACK_ADDED, TRACK_REMOVED } from '../tracks/actionTypes';
import { getLocalTracks } from '../tracks/functions.any';
@@ -54,7 +55,8 @@ import {
conferenceWillLeave,
createConference,
setLocalSubject,
setSubject
setSubject,
updateConferenceMetadata
} from './actions';
import { CONFERENCE_LEAVE_REASONS } from './constants';
import {
@@ -65,6 +67,7 @@ import {
restoreConferenceOptions
} from './functions';
import logger from './logger';
import { IConferenceMetadata } from './reducer';
/**
* Handler for before unload event.
@@ -124,6 +127,24 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.METADATA_UPDATED, (metadata: IConferenceMetadata) => {
dispatch(updateConferenceMetadata(metadata));
});
}
if (conference !== previousConference) {
dispatch(updateConferenceMetadata(null));
}
});
/**
* Makes sure to leave a failed conference in order to release any allocated
* resources like peer connections, emit participant left events, etc.

View File

@@ -27,7 +27,8 @@ import {
SET_PENDING_SUBJECT_CHANGE,
SET_ROOM,
SET_START_MUTED_POLICY,
SET_START_REACTIONS_MUTED
SET_START_REACTIONS_MUTED,
UPDATE_CONFERENCE_METADATA
} from './actionTypes';
import { isRoomValid } from './functions';
@@ -39,10 +40,23 @@ const DEFAULT_STATE = {
leaving: undefined,
locked: undefined,
membersOnly: undefined,
metadata: undefined,
password: undefined,
passwordRequired: undefined
};
export interface IConferenceMetadata {
recording?: {
isTranscribingEnabled: boolean;
};
whiteboard?: {
collabDetails: {
roomId: string;
roomKey: string;
};
};
}
export interface IJitsiConference {
addCommandListener: Function;
addLobbyMessageListener: Function;
@@ -141,6 +155,7 @@ export interface IConferenceState {
localSubject?: string;
locked?: string;
membersOnly?: IJitsiConference;
metadata?: IConferenceMetadata;
obfuscatedRoom?: string;
obfuscatedRoomSource?: string;
p2p?: Object;
@@ -247,6 +262,12 @@ ReducerRegistry.register<IConferenceState>('features/base/conference',
startAudioMutedPolicy: action.startAudioMutedPolicy,
startVideoMutedPolicy: action.startVideoMutedPolicy
};
case UPDATE_CONFERENCE_METADATA:
return {
...state,
metadata: action.metadata
};
}
return state;

View File

@@ -531,6 +531,8 @@ export interface IConfig {
};
recordingSharingUrl?: string;
recordings?: {
recordAudioAndVideo?: boolean;
showPrejoinWarning?: boolean;
suggestRecording?: boolean;
};
remoteVideoMenu?: {

View File

@@ -201,6 +201,7 @@ export default [
'remoteVideoMenu',
'roomPasswordNumberOfDigits',
'readOnlyName',
'recordings',
'replaceParticipant',
'resolution',
'salesforceUrl',

View File

@@ -13,6 +13,7 @@ import { withPixelLineHeight } from '../../../styles/functions.web';
import ConnectionStatus from './ConnectionStatus';
import Preview from './Preview';
import RecordingWarning from './RecordingWarning';
import UnsafeRoomWarning from './UnsafeRoomWarning';
interface IProps {
@@ -57,6 +58,11 @@ interface IProps {
*/
showDeviceStatus: boolean;
/**
* Indicates whether to display the recording warning.
*/
showRecordingWarning?: boolean;
/**
* If should show unsafe room warning when joining.
*/
@@ -167,6 +173,7 @@ const PreMeetingScreen = ({
children,
className,
showDeviceStatus,
showRecordingWarning,
showUnsafeRoomWarning,
skipPrejoinButton,
title,
@@ -200,6 +207,7 @@ const PreMeetingScreen = ({
{skipPrejoinButton}
{showUnsafeRoomWarning && <UnsafeRoomWarning />}
{showDeviceStatus && <DeviceStatus />}
{showRecordingWarning && <RecordingWarning />}
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { withPixelLineHeight } from '../../../styles/functions.web';
const useStyles = makeStyles()(theme => {
return {
warning: {
bottom: 0,
color: theme.palette.text03,
display: 'flex',
justifyContent: 'center',
...withPixelLineHeight(theme.typography.bodyShortRegular),
marginBottom: theme.spacing(3),
marginTop: theme.spacing(2),
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
position: 'absolute',
width: '100%',
'@media (max-width: 720px)': {
position: 'relative'
}
}
};
});
const RecordingWarning = () => {
const { t } = useTranslation();
const { classes } = useStyles();
return (
<div className = { classes.warning }>
{t('prejoin.recordingWarning')}
</div>
);
};
export default RecordingWarning;

View File

@@ -145,6 +145,24 @@ const useStyles = makeStyles()(theme => {
pointerEvents: 'none'
},
contextMenuItemIconDisabled: {
'& svg': {
fill: `${theme.palette.text03} !important`
}
},
contextMenuItemLabelDisabled: {
color: theme.palette.text03,
'&:hover': {
background: 'none'
},
'& svg': {
fill: theme.palette.text03
}
},
contextMenuItemDrawer: {
padding: '13px 16px'
},
@@ -233,13 +251,15 @@ const ContextMenuItem = ({
tabIndex = { onClick ? tabIndex : undefined }>
{customIcon ? customIcon
: icon && <Icon
className = { styles.contextMenuItemIcon }
className = { cx(styles.contextMenuItemIcon,
disabled && styles.contextMenuItemIconDisabled) }
size = { 20 }
src = { icon } />}
{text && (
<TextWithOverflow
className = { cx(styles.text,
_overflowDrawer && styles.drawerText,
disabled && styles.contextMenuItemLabelDisabled,
textClassName) }
overflowType = { overflowType } >
{text}

View File

@@ -1,3 +1,5 @@
import base64js from 'base64-js';
import { timeoutPromise } from './timeoutPromise';
/**
@@ -43,3 +45,37 @@ export function doGetJSON(url: string, retry?: boolean, options?: Object) {
return fetchPromise;
}
/**
* Encodes strings to Base64URL.
*
* @param {any} data - The byte array to encode.
* @returns {string}
*/
export const encodeToBase64URL = (data: string): string => base64js
.fromByteArray(new window.TextEncoder().encode(data))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
/**
* Decodes strings from Base64URL.
*
* @param {string} data - The byte array to decode.
* @returns {string}
*/
export const decodeFromBase64URL = (data: string): string => {
let s = data;
// Convert from Base64URL to Base64.
if (s.length % 4 === 2) {
s += '==';
} else if (s.length % 4 === 3) {
s += '=';
}
s = s.replace(/-/g, '+').replace(/_/g, '/');
// Convert Base64 to a byte array.
return new window.TextDecoder().decode(base64js.toByteArray(s));
};

View File

@@ -161,8 +161,7 @@ export const getCurrentRoomId = (stateful: IStateful) => {
export const isInBreakoutRoom = (stateful: IStateful) => {
const conference = getCurrentConference(stateful);
return conference?.getBreakoutRooms()
?.isBreakoutRoom();
return conference?.getBreakoutRooms()?.isBreakoutRoom();
};
/**

View File

@@ -2,7 +2,7 @@ import { AnyAction } from 'redux';
import { IReduxState, IStore } from '../app/types';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import { CONFERENCE_JOINED, ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { IJitsiConference } from '../base/conference/reducer';
import { openDialog } from '../base/dialog/actions';
@@ -92,14 +92,6 @@ MiddlewareRegistry.register(store => next => action => {
_addChatMsgListener(action.conference, store);
break;
case OPEN_CHAT:
unreadCount = 0;
if (typeof APP !== 'undefined') {
APP.API.notifyChatUpdated(unreadCount, true);
}
break;
case CLOSE_CHAT: {
const isPollTabOpen = getState()['features/chat'].isPollsTabFocused;
@@ -115,6 +107,38 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const state = store.getState();
if (!isReactionsEnabled(state)) {
return;
}
const { participant, data } = action;
if (data?.name === ENDPOINT_REACTION_NAME) {
store.dispatch(pushReactions(data.reactions));
_handleReceivedMessage(store, {
id: participant.getId(),
message: getReactionMessageFromBuffer(data.reactions),
privateMessage: false,
lobbyChat: false,
timestamp: data.timestamp
}, false, true);
}
break;
}
case OPEN_CHAT:
unreadCount = 0;
if (typeof APP !== 'undefined') {
APP.API.notifyChatUpdated(unreadCount, true);
}
break;
case SET_IS_POLL_TAB_FOCUSED: {
dispatch(resetNbUnreadPollsMessages());
break;
@@ -252,33 +276,6 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
}
);
// TODO: clean this up, there should be a central place that dispatches this. -saghul
conference.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(...args: any) => {
const state = store.getState();
if (!isReactionsEnabled(state)) {
return;
}
if (args && args.length >= 2) {
const [ { _id }, eventData ] = args;
if (eventData.name === ENDPOINT_REACTION_NAME) {
store.dispatch(pushReactions(eventData.reactions));
_handleReceivedMessage(store, {
id: _id,
message: getReactionMessageFromBuffer(eventData.reactions),
privateMessage: false,
lobbyChat: false,
timestamp: eventData.timestamp
}, false, true);
}
}
});
conference.on(
JitsiConferenceEvents.CONFERENCE_ERROR, (errorType: string, error: Error) => {
errorType === JitsiConferenceErrors.CHAT_ERROR && _handleChatError(store, error);

View File

@@ -1,10 +1,7 @@
import i18next from 'i18next';
import { CONFERENCE_JOINED, KICKED_OUT } from '../base/conference/actionTypes';
import { IJitsiConference } from '../base/conference/reducer';
import { ENDPOINT_MESSAGE_RECEIVED, KICKED_OUT } from '../base/conference/actionTypes';
import { hangup } from '../base/connection/actions.web';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { openAllowToggleCameraDialog, setCameraFacingMode } from '../base/tracks/actions.web';
@@ -14,8 +11,15 @@ import './middleware.any';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case CONFERENCE_JOINED: {
_addSetCameraFacingModeListener(action.conference);
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
if (data?.name === CAMERA_FACING_MODE_MESSAGE) {
APP.store.dispatch(openAllowToggleCameraDialog(
/* onAllow */ () => APP.store.dispatch(setCameraFacingMode(data.facingMode)),
/* initiatorId */ participant.getId()
));
}
break;
}
@@ -34,28 +38,3 @@ MiddlewareRegistry.register(store => next => action => {
return next(action);
});
/**
* Registers listener for {@link JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED} that
* will perform various chat related activities.
*
* @param {IJitsiConference} conference - The conference.
* @returns {void}
*/
function _addSetCameraFacingModeListener(conference: IJitsiConference) {
conference.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(...args: any) => {
if (args && args.length >= 2) {
const [ sender, eventData ] = args;
if (eventData.name === CAMERA_FACING_MODE_MESSAGE) {
APP.store.dispatch(openAllowToggleCameraDialog(
/* onAllow */ () => APP.store.dispatch(setCameraFacingMode(eventData.facingMode)),
/* initiatorId */ sender._id
));
}
}
}
);
}

View File

@@ -56,6 +56,7 @@ class SharedDocument extends PureComponent<IProps> {
style = { styles.sharedDocContainer }>
<WebView
hideKeyboardAccessoryView = { true }
incognito = { true }
renderLoading = { this._renderLoading }
source = {{ uri: _documentUrl ?? '' }}
startInLoadingState = { true }

View File

@@ -3,12 +3,11 @@ import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import {
CONFERENCE_JOINED,
CONFERENCE_WILL_LEAVE
CONFERENCE_WILL_LEAVE,
ENDPOINT_MESSAGE_RECEIVED
} from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { getLocalParticipant, getParticipantCount } from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { TRACK_ADDED, TRACK_REMOVED, TRACK_UPDATED } from '../base/tracks/actionTypes';
@@ -28,27 +27,22 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
FaceLandmarksDetector.init(store);
}
// allow using remote face centering data when local face centering is not enabled
action.conference.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(participant: IParticipant | undefined, eventData: any) => {
if (!participant || !eventData || !participant.getId) {
return;
}
return next(action);
} else if (action.type === ENDPOINT_MESSAGE_RECEIVED) {
// Allow using remote face centering data when local face centering is not enabled.
const { participant, data } = action;
if (eventData.type === FACE_BOX_EVENT_TYPE) {
dispatch({
type: UPDATE_FACE_COORDINATES,
faceBox: eventData.faceBox,
id: participant.getId()
});
}
if (data?.type === FACE_BOX_EVENT_TYPE) {
dispatch({
type: UPDATE_FACE_COORDINATES,
faceBox: data.faceBox,
id: participant.getId()
});
}
return next(action);
}
if (!isEnabled) {
return next(action);
}
@@ -57,7 +51,7 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
case CONFERENCE_WILL_LEAVE : {
FaceLandmarksDetector.stopDetection(store);
return next(action);
break;
}
case TRACK_ADDED: {
const { jitsiTrack: { isLocal, videoType }, muted } = action.track;
@@ -67,18 +61,18 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
FaceLandmarksDetector.startDetection(store, action.track);
}
return next(action);
break;
}
case TRACK_UPDATED: {
const { jitsiTrack: { isLocal, videoType } } = action.track;
if (videoType !== 'camera' || !isLocal()) {
return next(action);
break;
}
const { muted } = action.track;
if (muted !== undefined) {
if (typeof muted !== 'undefined') {
// addresses video mute state changes
if (muted) {
FaceLandmarksDetector.stopDetection(store);
@@ -87,7 +81,7 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
}
}
return next(action);
break;
}
case TRACK_REMOVED: {
const { jitsiTrack: { isLocal, videoType } } = action.track;
@@ -96,7 +90,7 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
FaceLandmarksDetector.stopDetection(store);
}
return next(action);
break;
}
case ADD_FACE_LANDMARKS: {
const state = getState();
@@ -110,7 +104,7 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
// Disabling for now as there is no value of having the data in speakerstats at the server
// sendFaceExpressionToServer(conference, faceLandmarks);
return next(action);
break;
}
case NEW_FACE_COORDINATES: {
const state = getState();
@@ -127,6 +121,8 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => (action: AnyA
faceBox,
id: localParticipant?.id
});
break;
}
}

View File

@@ -76,6 +76,7 @@ class DialInSummary extends PureComponent<IProps> {
<JitsiScreen
style = { styles.backDrop }>
<WebView
incognito = { true }
onError = { this._onError }
onShouldStartLoadWithRequest = { this._onNavigate }
renderLoading = { this._renderLoading }

View File

@@ -17,7 +17,7 @@ import SharedVideo from '../../shared-video/components/web/SharedVideo';
import Captions from '../../subtitles/components/web/Captions';
import { setTileView } from '../../video-layout/actions.web';
import Whiteboard from '../../whiteboard/components/web/Whiteboard';
import { isWhiteboardReady } from '../../whiteboard/functions';
import { isWhiteboardEnabled } from '../../whiteboard/functions';
import { setSeeWhatIsBeingShared } from '../actions.web';
import { getLargeVideoParticipant } from '../functions';
@@ -112,7 +112,7 @@ interface IProps {
/**
* Whether or not the whiteboard is ready to be used.
*/
_whiteboardReady: boolean;
_whiteboardEnabled: boolean;
/**
* The Redux dispatch function.
@@ -193,7 +193,7 @@ class LargeVideo extends Component<IProps> {
_isChatOpen,
_noAutoPlayVideo,
_showDominantSpeakerBadge,
_whiteboardReady
_whiteboardEnabled
} = this.props;
const style = this._getCustomStyles();
const className = `videocontainer${_isChatOpen ? ' shift-right' : ''}`;
@@ -205,7 +205,7 @@ class LargeVideo extends Component<IProps> {
ref = { this._containerRef }
style = { style }>
<SharedVideo />
{_whiteboardReady && <Whiteboard />}
{_whiteboardEnabled && <Whiteboard />}
<div id = 'etherpad' />
<Watermarks />
@@ -378,7 +378,7 @@ function _mapStateToProps(state: IReduxState) {
_verticalFilmstripWidth: verticalFilmstripWidth.current,
_verticalViewMaxWidth: getVerticalViewMaxWidth(state),
_visibleFilmstrip: visible,
_whiteboardReady: isWhiteboardReady(state)
_whiteboardEnabled: isWhiteboardEnabled(state)
};
}

View File

@@ -6,7 +6,8 @@ import { IStore } from '../app/types';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED
CONFERENCE_JOINED,
ENDPOINT_MESSAGE_RECEIVED
} from '../base/conference/actionTypes';
import { conferenceWillJoin } from '../base/conference/actions';
import {
@@ -79,6 +80,13 @@ MiddlewareRegistry.register(store => next => action => {
return _conferenceFailed(store, next, action);
case CONFERENCE_JOINED:
return _conferenceJoined(store, next, action);
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
_maybeSendLobbyNotification(participant, data, store);
break;
}
case KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED: {
// We need the full update result to be in the store already
const result = next(action);
@@ -166,13 +174,6 @@ StateListenerRegistry.register(
dispatch(updateLobbyParticipantOnLeave(id));
});
});
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED, (origin: any, sender: any) =>
_maybeSendLobbyNotification(origin, sender, {
dispatch,
getState
})
);
}
}
);

View File

@@ -16,6 +16,7 @@ import {
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN,
ENDPOINT_MESSAGE_RECEIVED,
SET_ROOM
} from '../../base/conference/actionTypes';
import { JITSI_CONFERENCE_URL_KEY } from '../../base/conference/constants';
@@ -184,6 +185,22 @@ externalAPIEnabled && MiddlewareRegistry.register(store => next => action => {
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
if (data?.name === ENDPOINT_TEXT_MESSAGE_NAME) {
sendEvent(
store,
ENDPOINT_TEXT_MESSAGE_RECEIVED,
/* data */ {
message: data.text,
senderId: participant.getId()
});
}
break;
}
case ENTER_PICTURE_IN_PICTURE:
sendEvent(store, type, /* data */ {});
break;
@@ -419,24 +436,6 @@ function _unregisterForNativeEvents() {
function _registerForEndpointTextMessages(store: IStore) {
const conference = getCurrentConference(store.getState());
conference?.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(...args: any[]) => {
if (args && args.length >= 2) {
const [ sender, eventData ] = args;
if (eventData.name === ENDPOINT_TEXT_MESSAGE_NAME) {
sendEvent(
store,
ENDPOINT_TEXT_MESSAGE_RECEIVED,
/* data */ {
message: eventData.text,
senderId: sender._id
});
}
}
});
conference?.on(
JitsiConferenceEvents.MESSAGE_RECEIVED,
(id: string, message: string, timestamp: number) => {

View File

@@ -13,6 +13,7 @@ import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
// @ts-ignore
import WelcomePage from '../../../welcome/components/WelcomePage';
import { isWelcomePageEnabled } from '../../../welcome/functions';
import Whiteboard from '../../../whiteboard/components/native/Whiteboard';
import { _ROOT_NAVIGATION_READY } from '../actionTypes';
import { rootNavigationRef } from '../rootNavigationContainerRef';
import { screen } from '../routes';
@@ -23,7 +24,8 @@ import {
navigationContainerTheme,
preJoinScreenOptions,
unsafeMeetingScreenOptions,
welcomeScreenOptions
welcomeScreenOptions,
whiteboardScreenOptions
} from '../screenOptions';
import ConnectingPage from './ConnectingPage';
@@ -94,6 +96,10 @@ const RootNavigationContainer = ({ dispatch, isUnsafeRoomWarningAvailable, isWel
component = { ConnectingPage }
name = { screen.connecting }
options = { connectingScreenOptions } />
<RootStack.Screen // @ts-ignore
component = { Whiteboard }
name = { screen.conference.whiteboard }
options = { whiteboardScreenOptions } />
<RootStack.Screen
component = { Prejoin }
name = { screen.preJoin }

View File

@@ -22,7 +22,8 @@ export const screen = {
security: 'Security Options',
sharedDocument: 'Shared document',
speakerStats: 'Speaker Stats',
subtitles: 'Subtitles'
subtitles: 'Subtitles',
whiteboard: 'Whiteboard'
},
connecting: 'Connecting',
dialInSummary: 'Dial-In Info',

View File

@@ -188,6 +188,19 @@ export const connectingScreenOptions = {
headerShown: false
};
/**
* Screen options for the whiteboard screen.
*/
export const whiteboardScreenOptions = {
gestureEnabled: true,
headerStyle: {
backgroundColor: BaseTheme.palette.ui01
},
headerTitleStyle: {
color: BaseTheme.palette.text01
}
};
/**
* Screen options for pre-join screen.
*/

View File

@@ -1,6 +1,7 @@
import { DOMParser } from '@xmldom/xmldom';
import { Platform } from 'react-native';
import BackgroundTimer from 'react-native-background-timer';
import { TextDecoder, TextEncoder } from 'text-encoding';
import 'promise.allsettled/auto'; // Promise.allSettled.
import 'react-native-url-polyfill/auto'; // Complete URL polyfill.
@@ -313,4 +314,7 @@ function _visitNode(node, callback) {
global.sessionStorage = new Storage();
}
global.TextDecoder = TextDecoder;
global.TextEncoder = TextEncoder;
})(global || window || this); // eslint-disable-line no-invalid-this

View File

@@ -305,7 +305,7 @@ export function isBreakoutRoomRenameAllowed(state: IReduxState) {
const isLocalModerator = isLocalParticipantModerator(state);
const conference = getCurrentConference(state);
const isRenameBreakoutRoomsSupported
= conference?.getBreakoutRooms().isFeatureSupported(BREAKOUT_ROOMS_RENAME_FEATURE);
= conference?.getBreakoutRooms()?.isFeatureSupported(BREAKOUT_ROOMS_RENAME_FEATURE) ?? false;
return isLocalModerator && isRenameBreakoutRoomsSupported;
}

View File

@@ -1,7 +1,6 @@
import { IStore } from '../app/types';
import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
import { ENDPOINT_MESSAGE_RECEIVED, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { playSound } from '../base/sounds/actions';
@@ -59,36 +58,32 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const result = next(action);
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
const { conference } = action;
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
const isNewPoll = data.type === COMMAND_NEW_POLL;
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(user: any, data: any) => {
const isNewPoll = data.type === COMMAND_NEW_POLL;
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? user._id : undefined,
voterId: isNewPoll ? undefined : user._id
}, dispatch, getState);
});
conference.on(JitsiConferenceEvents.NON_PARTICIPANT_MESSAGE_RECEIVED,
(id: any, data: any) => {
const isNewPoll = data.type === COMMAND_NEW_POLL;
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? id : undefined,
voterId: isNewPoll ? undefined : id
}, dispatch, getState);
});
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? participant.getId() : undefined,
voterId: isNewPoll ? undefined : participant.getId()
}, dispatch, getState);
break;
}
// Middleware triggered when a poll is received
case RECEIVE_POLL: {
case NON_PARTICIPANT_MESSAGE_RECEIVED: {
const { id, json: data } = action;
const isNewPoll = data.type === COMMAND_NEW_POLL;
_handleReceivePollsMessage({
...data,
senderId: isNewPoll ? id : undefined,
voterId: isNewPoll ? undefined : id
}, dispatch, getState);
break;
}
case RECEIVE_POLL: {
const state = getState();
if (arePollsDisabled(state)) {

View File

@@ -168,12 +168,14 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
{
isFocused
&& <View style = { largeVideoContainerStyles as StyleProp<ViewStyle> }>
<View style = { styles.displayRoomNameBackdrop as StyleProp<TextStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.preJoinRoomName as StyleProp<TextStyle> }>
{ roomName }
</Text>
<View style = { styles.conferenceInfo as StyleProp<ViewStyle> }>
<View style = { styles.displayRoomNameBackdrop as StyleProp<TextStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.preJoinRoomName as StyleProp<TextStyle> }>
{ roomName }
</Text>
</View>
</View>
<LargeVideo />
</View>

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
StyleProp,
Text,
TextStyle,
View,
ViewStyle
} from 'react-native';
import { preJoinStyles as styles } from './styles';
const RecordingWarning = (): JSX.Element => {
const { t } = useTranslation();
return (
<View style = { styles.recordingWarning as StyleProp<ViewStyle> }>
<Text
numberOfLines = { 1 }
style = { styles.recordingWarningText as StyleProp<TextStyle> }>
{ t('prejoin.recordingWarning') }
</Text>
</View>
);
};
export default RecordingWarning;

View File

@@ -106,18 +106,33 @@ export const preJoinStyles = {
textAlign: 'center'
},
displayRoomNameBackdrop: {
conferenceInfo: {
alignSelf: 'center',
backgroundColor: BaseTheme.palette.uiBackground,
borderRadius: BaseTheme.shape.borderRadius,
marginTop: BaseTheme.spacing[3],
opacity: 0.7,
paddingHorizontal: BaseTheme.spacing[3],
paddingVertical: BaseTheme.spacing[1],
position: 'absolute',
maxWidth: 243,
maxWidth: 273,
zIndex: 1
},
displayRoomNameBackdrop: {
backgroundColor: BaseTheme.palette.uiBackground,
borderRadius: BaseTheme.shape.borderRadius,
opacity: 0.7,
paddingHorizontal: BaseTheme.spacing[3],
paddingVertical: BaseTheme.spacing[1]
},
recordingWarning: {
display: 'flex',
justifyContent: 'center',
lineHeight: 22,
marginBottom: BaseTheme.spacing[2],
marginTop: BaseTheme.spacing[1],
width: 'auto'
},
recordingWarningText: {
color: BaseTheme.palette.text03
},
unsafeRoomWarningContainer: {
height: '100%',
width: '100%',

View File

@@ -111,6 +111,11 @@ interface IProps {
*/
showErrorOnJoin: boolean;
/**
* If the recording warning is visible or not.
*/
showRecordingWarning: boolean;
/**
* If should show unsafe room warning when joining.
*/
@@ -219,6 +224,7 @@ const Prejoin = ({
showCameraPreview,
showDialog,
showErrorOnJoin,
showRecordingWarning,
showUnsafeRoomWarning,
unsafeRoomConsent,
updateSettings: dispatchUpdateSettings,
@@ -390,6 +396,7 @@ const Prejoin = ({
return (
<PreMeetingScreen
showDeviceStatus = { deviceStatusVisible }
showRecordingWarning = { showRecordingWarning }
showUnsafeRoomWarning = { showUnsafeRoomWarning }
title = { t('prejoin.joinMeeting') }
videoMuted = { !showCameraPreview }
@@ -483,6 +490,7 @@ function mapStateToProps(state: IReduxState) {
const { joiningInProgress } = state['features/prejoin'];
const { room } = state['features/base/conference'];
const { unsafeRoomConsent } = state['features/base/premeeting'];
const { showPrejoinWarning: showRecordingWarning } = state['features/base/config'].recordings ?? {};
return {
deviceStatusVisible: isDeviceStatusVisible(state),
@@ -496,6 +504,7 @@ function mapStateToProps(state: IReduxState) {
showCameraPreview: !isVideoMutedByUser(state),
showDialog: isJoinByPhoneDialogVisible(state),
showErrorOnJoin,
showRecordingWarning: Boolean(showRecordingWarning),
showUnsafeRoomWarning: isInsecureRoomName(room) && isUnsafeRoomWarningEnabled(state),
unsafeRoomConsent,
videoTrack: getLocalJitsiVideoTrack(state)

View File

@@ -83,3 +83,12 @@ export const START_LOCAL_RECORDING = 'START_LOCAL_RECORDING';
* }
*/
export const STOP_LOCAL_RECORDING = 'STOP_LOCAL_RECORDING';
/**
* Indicates that the start recording notification has been shown.
*
* {
* type: SET_START_RECORDING_NOTIFICATION_SHOWN
* }
*/
export const SET_START_RECORDING_NOTIFICATION_SHOWN = 'SET_START_RECORDING_NOTIFICATION_SHOWN';

View File

@@ -19,6 +19,7 @@ import {
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
import { INotificationProps } from '../notifications/types';
import { setRequestingSubtitles } from '../subtitles/actions.any';
import { isRecorderTranscriptionsRunning } from '../transcribing/functions';
import {
CLEAR_RECORDING_SESSIONS,
@@ -26,6 +27,7 @@ import {
SET_MEETING_HIGHLIGHT_BUTTON_STATE,
SET_PENDING_RECORDING_NOTIFICATION_UID,
SET_SELECTED_RECORDING_SERVICE,
SET_START_RECORDING_NOTIFICATION_SHOWN,
SET_STREAM_KEY,
START_LOCAL_RECORDING,
STOP_LOCAL_RECORDING
@@ -57,6 +59,20 @@ export function clearRecordingSessions() {
};
}
/**
* Marks the start recording notification as shown.
*
* @returns {{
* type: SET_START_RECORDING_NOTIFICATION_SHOWN
* }}
*/
export function setStartRecordingNotificationShown() {
return {
type: SET_START_RECORDING_NOTIFICATION_SHOWN
};
}
/**
* Sets the meeting highlight button disable state.
*
@@ -389,19 +405,22 @@ export function stopLocalVideoRecording() {
*/
export function showStartRecordingNotificationWithCallback(openRecordingDialog: Function) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
let state = getState();
const { recordings } = state['features/base/config'];
const { suggestRecording } = recordings || {};
const recordButtonProps = getRecordButtonProps(state);
const isAlreadyRecording = isRecordingRunning(state);
const isAlreadyRecording = isRecordingRunning(state) || isRecorderTranscriptionsRunning(state);
const wasNotificationShown = state['features/recording'].wasStartRecordingSuggested;
if (!suggestRecording
|| isAlreadyRecording
|| !recordButtonProps.visible
|| recordButtonProps.disabled) {
|| recordButtonProps.disabled
|| wasNotificationShown) {
return;
}
dispatch(setStartRecordingNotificationShown());
dispatch(showNotification({
titleKey: 'notify.suggestRecordingTitle',
descriptionKey: 'notify.suggestRecordingDescription',
@@ -409,9 +428,9 @@ export function showStartRecordingNotificationWithCallback(openRecordingDialog:
customActionType: [ BUTTON_TYPES.PRIMARY ],
customActionNameKey: [ 'notify.suggestRecordingAction' ],
customActionHandler: [ () => {
state = getState();
const isModerator = isLocalParticipantModerator(state);
const { recordingService } = state['features/base/config'];
const canBypassDialog = isModerator
&& recordingService?.enabled
&& isJwtFeatureEnabled(state, 'recording', true);
@@ -432,7 +451,7 @@ export function showStartRecordingNotificationWithCallback(openRecordingDialog:
});
if (autoTranscribeOnRecord) {
dispatch(setRequestingSubtitles(true, false));
dispatch(setRequestingSubtitles(true, false, null));
}
} else {
openRecordingDialog();

View File

@@ -3,7 +3,13 @@ import { WithTranslation } from 'react-i18next';
import { IReduxState } from '../../app/types';
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import { getSessionStatusToShow } from '../functions';
import { isRecorderTranscriptionsRunning } from '../../transcribing/functions';
import {
getActiveSession,
getSessionStatusToShow,
isRecordingRunning,
isRemoteParticipantRecordingLocally
} from '../functions';
interface IProps extends WithTranslation {
@@ -13,10 +19,16 @@ interface IProps extends WithTranslation {
*/
_iAmRecorder: boolean;
/**
* Whether this meeting is being transcribed.
*/
_isTranscribing: boolean;
*/
_isTranscribing: boolean;
/**
* Whether the recording/livestreaming/transcriber is currently running.
*/
_isVisible: boolean;
/**
* The status of the higher priority session.
@@ -34,91 +46,19 @@ interface IProps extends WithTranslation {
mode: string;
}
/**
* State of the component.
*/
interface IState {
/**
* True if the label status is stale, so it needs to be removed.
*/
staleLabel: boolean;
}
/**
* The timeout after a label is considered stale. See {@code _updateStaleStatus}
* for more details.
*/
const STALE_TIMEOUT = 10 * 1000;
/**
* Abstract class for the {@code RecordingLabel} component.
*/
export default class AbstractRecordingLabel extends Component<IProps, IState> {
_mounted: boolean;
/**
* Implements {@code Component#getDerivedStateFromProps}.
*
* @inheritdoc
*/
static getDerivedStateFromProps(props: IProps, prevState: IState) {
return {
staleLabel: props._status !== JitsiRecordingConstants.status.OFF
&& prevState.staleLabel ? false : prevState.staleLabel
};
}
/**
* Initializes a new {@code AbstractRecordingLabel} component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
staleLabel: true
};
this._updateStaleStatus(undefined, props);
}
/**
* Implements React {@code Component}'s componentDidMount.
*
* @inheritdoc
*/
componentDidMount() {
this._mounted = true;
}
/**
* Implements React {@code Component}'s componentWillUnmount.
*
* @inheritdoc
*/
componentWillUnmount() {
this._mounted = false;
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
componentDidUpdate(prevProps: IProps) {
this._updateStaleStatus(prevProps, this.props);
}
export default class AbstractRecordingLabel extends Component<IProps> {
/**
* Implements React {@code Component}'s render.
*
* @inheritdoc
*/
render() {
return (this.props._status || this.props._isTranscribing) && !this.state.staleLabel && !this.props._iAmRecorder
? this._renderLabel() : null;
const { _iAmRecorder, _isVisible } = this.props;
return _isVisible && !_iAmRecorder ? this._renderLabel() : null;
}
/**
@@ -130,33 +70,6 @@ export default class AbstractRecordingLabel extends Component<IProps, IState> {
_renderLabel(): React.ReactNode | null {
return null;
}
/**
* Updates the stale status of the label on a prop change. A label is stale
* if it's in a {@code _status} that doesn't need to be rendered anymore.
*
* @param {IProps} oldProps - The previous props of the component.
* @param {IProps} newProps - The new props of the component.
* @returns {void}
*/
_updateStaleStatus(oldProps: IProps | undefined, newProps: IProps) {
if (newProps._status === JitsiRecordingConstants.status.OFF) {
if (oldProps?._status !== JitsiRecordingConstants.status.OFF) {
setTimeout(() => {
if (!this._mounted) {
return;
}
// Only if it's still OFF.
if (this.props._status === JitsiRecordingConstants.status.OFF) {
this.setState({
staleLabel: true
});
}
}, STALE_TIMEOUT);
}
}
}
}
/**
@@ -172,10 +85,18 @@ export default class AbstractRecordingLabel extends Component<IProps, IState> {
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const { mode } = ownProps;
const isLiveStreamingLabel = mode === JitsiRecordingConstants.mode.STREAM;
const _isTranscribing = isRecorderTranscriptionsRunning(state);
const isLivestreamingRunning = Boolean(getActiveSession(state, JitsiRecordingConstants.mode.STREAM));
const _isVisible = isLiveStreamingLabel
? isLivestreamingRunning // this is the livestreaming label
: isRecordingRunning(state) || isRemoteParticipantRecordingLocally(state)
|| _isTranscribing; // this is the recording label
return {
_isVisible,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_isTranscribing: state['features/transcribing'].isTranscribing,
_isTranscribing,
_status: getSessionStatusToShow(state, mode)
};
}

View File

@@ -7,7 +7,8 @@ import { isLocalParticipantModerator } from '../../../base/participants/function
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { getActiveSession } from '../../functions';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { getActiveSession, isCloudRecordingRunning } from '../../functions';
import { getLiveStreaming } from './functions';
@@ -140,7 +141,7 @@ export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
}
// disable the button if the recording is running.
if (visible && getActiveSession(state, JitsiRecordingConstants.mode.FILE)) {
if (visible && (isCloudRecordingRunning(state) || isRecorderTranscriptionsRunning(state))) {
_disabled = true;
_tooltip = 'dialog.liveStreamingDisabledBecauseOfActiveRecordingTooltip';
}

View File

@@ -4,14 +4,17 @@ import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { MEET_FEATURES } from '../../../base/jwt/constants';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { hideNotification, showNotification } from '../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../../notifications/constants';
import { iAmVisitor } from '../../../visitors/functions';
import { highlightMeetingMoment } from '../../actions.any';
import { PROMPT_RECORDING_NOTIFICATION_ID } from '../../constants';
import { getActiveSession, getRecordButtonProps, isHighlightMeetingMomentDisabled } from '../../functions';
import {
getRecordButtonProps,
isCloudRecordingRunning,
isHighlightMeetingMomentDisabled
} from '../../functions';
import { StartRecordingDialog } from './index';
@@ -103,7 +106,7 @@ export default class AbstractHighlightButton<P extends IProps, S={}> extends Com
* }}
*/
export function _abstractMapStateToProps(state: IReduxState) {
const isRecordingRunning = getActiveSession(state, JitsiRecordingConstants.mode.FILE);
const isRecordingRunning = isCloudRecordingRunning(state);
const isButtonDisabled = isHighlightMeetingMomentDisabled(state);
const { webhookProxyUrl } = state['features/base/config'];
const _iAmVisitor = iAmVisitor(state);

View File

@@ -6,7 +6,7 @@ import { MEET_FEATURES } from '../../../base/jwt/constants';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { getRecordButtonProps, isRecordingRunning } from '../../functions';
import { canStopRecording, getRecordButtonProps } from '../../functions';
/**
* The type of the React {@code Component} props of
@@ -131,7 +131,7 @@ export function _mapStateToProps(state: IReduxState) {
return {
_disabled,
_isRecordingRunning: isRecordingRunning(state),
_isRecordingRunning: canStopRecording(state),
_tooltip,
visible
};

View File

@@ -12,7 +12,7 @@ import { showErrorNotification } from '../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions';
import { RECORDING_TYPES } from '../../constants';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../constants';
import { isRecordingSharingEnabled, shouldAutoTranscribeOnRecord, supportsLocalRecording } from '../../functions';
export interface IProps extends WithTranslation {
@@ -32,6 +32,11 @@ export interface IProps extends WithTranslation {
*/
_conference?: IJitsiConference;
/**
* Whether subtitles should be displayed or not.
*/
_displaySubtitles?: boolean;
/**
* Whether to show file recordings service, even if integrations
* are enabled.
@@ -59,6 +64,11 @@ export interface IProps extends WithTranslation {
*/
_rToken: string;
/**
* Whether the record audio / video option is enabled by default.
*/
_recordAudioAndVideo: boolean;
/**
* Whether or not the local participant is screensharing.
*/
@@ -69,6 +79,11 @@ export interface IProps extends WithTranslation {
*/
_screenshotCaptureEnabled: boolean;
/**
* The selected language for subtitles.
*/
_subtitlesLanguage: string | null;
/**
* The dropbox access token.
*/
@@ -177,8 +192,8 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
isValidating: false,
userName: undefined,
sharingEnabled: true,
shouldRecordAudioAndVideo: true,
shouldRecordTranscription: true,
shouldRecordAudioAndVideo: this.props._recordAudioAndVideo,
shouldRecordTranscription: this.props._autoTranscribeOnRecord,
spaceLeft: undefined,
selectedRecordingService,
localRecordingOnlySelf: false
@@ -335,10 +350,11 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
_onSubmit() {
const {
_appKey,
_autoTranscribeOnRecord,
_conference,
_displaySubtitles,
_isDropboxEnabled,
_rToken,
_subtitlesLanguage,
_token,
dispatch
} = this.props;
@@ -398,10 +414,15 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
});
}
if (_autoTranscribeOnRecord || this.state.shouldRecordTranscription) {
dispatch(setRequestingSubtitles(true, false));
if (this.state.selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
&& this.state.shouldRecordTranscription) {
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage));
}
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: this.state.shouldRecordTranscription
});
return true;
}
@@ -430,34 +451,32 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Component's own props.
* @private
* @returns {{
* _appKey: string,
* _autoTranscribeOnRecord: boolean,
* _conference: JitsiConference,
* _fileRecordingsServiceEnabled: boolean,
* _fileRecordingsServiceSharingEnabled: boolean,
* _isDropboxEnabled: boolean,
* _rToken:string,
* _tokenExpireDate: number,
* _token: string
* }}
* @returns {IProps}
*/
export function mapStateToProps(state: IReduxState, _ownProps: any) {
const {
recordingService,
dropbox = { appKey: undefined },
localRecording
localRecording,
recordings = { recordAudioAndVideo: true }
} = state['features/base/config'];
const {
_displaySubtitles,
_language: _subtitlesLanguage
} = state['features/subtitles'];
return {
_appKey: dropbox.appKey ?? '',
_autoTranscribeOnRecord: shouldAutoTranscribeOnRecord(state),
_conference: state['features/base/conference'].conference,
_displaySubtitles,
_fileRecordingsServiceEnabled: recordingService?.enabled ?? false,
_fileRecordingsServiceSharingEnabled: isRecordingSharingEnabled(state),
_isDropboxEnabled: isDropboxEnabled(state),
_localRecordingEnabled: !localRecording?.disable,
_rToken: state['features/dropbox'].rToken ?? '',
_recordAudioAndVideo: recordings?.recordAudioAndVideo ?? true,
_subtitlesLanguage,
_tokenExpireDate: state['features/dropbox'].expireDate,
_token: state['features/dropbox'].token ?? ''
};

View File

@@ -9,7 +9,7 @@ import { _abstractMapStateToProps } from '../../../base/dialog/functions';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox/actions';
import { isVpaasMeeting } from '../../../jaas/functions';
import { canStartTranscribing } from '../../../subtitles/functions';
import { canAddTranscriber } from '../../../transcribing/functions';
import { RECORDING_TYPES } from '../../constants';
import { supportsLocalRecording } from '../../functions';
@@ -418,7 +418,7 @@ export function mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
isVpaas: isVpaasMeeting(state),
_canStartTranscribing: canStartTranscribing(state),
_canStartTranscribing: canAddTranscriber(state),
_hideStorageWarning: Boolean(recordingService?.hideStorageWarning),
_isModerator: isLocalParticipantModerator(state),
_localRecordingAvailable,

View File

@@ -9,6 +9,7 @@ import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { setVideoMuted } from '../../../base/media/actions';
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
import { stopLocalVideoRecording } from '../../actions';
import { RECORDING_METADATA_ID } from '../../constants';
import { getActiveSession } from '../../functions';
import { ISessionData } from '../../reducer';
@@ -25,6 +26,11 @@ export interface IProps extends WithTranslation {
*/
_conference?: IJitsiConference;
/**
* Whether subtitles should be displayed or not.
*/
_displaySubtitles?: boolean;
/**
* The redux representation of the recording session to be stopped.
*/
@@ -35,6 +41,11 @@ export interface IProps extends WithTranslation {
*/
_localRecording: boolean;
/**
* The selected language for subtitles.
*/
_subtitlesLanguage: string | null;
/**
* The redux dispatch function.
*/
@@ -76,22 +87,32 @@ export default class AbstractStopRecordingDialog<P extends IProps>
_onSubmit() {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
if (this.props._localRecording) {
this.props.dispatch(stopLocalVideoRecording());
if (this.props.localRecordingVideoStop) {
this.props.dispatch(setVideoMuted(true));
}
} else {
const { _fileRecordingSession } = this.props;
const {
_conference,
_displaySubtitles,
_fileRecordingSession,
_localRecording,
_subtitlesLanguage,
dispatch,
localRecordingVideoStop
} = this.props;
if (_fileRecordingSession) {
this.props._conference?.stopRecording(_fileRecordingSession.id);
this._toggleScreenshotCapture();
if (_localRecording) {
dispatch(stopLocalVideoRecording());
if (localRecordingVideoStop) {
dispatch(setVideoMuted(true));
}
} else if (_fileRecordingSession) {
_conference?.stopRecording(_fileRecordingSession.id);
this._toggleScreenshotCapture();
}
// TODO: this should be an action in transcribing. -saghul
this.props.dispatch(setRequestingSubtitles(false, false, null));
this.props.dispatch(setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage));
this.props._conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false
});
return true;
}
@@ -112,16 +133,20 @@ export default class AbstractStopRecordingDialog<P extends IProps>
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _conference: JitsiConference,
* _fileRecordingSession: Object
* }}
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
const {
_displaySubtitles,
_language: _subtitlesLanguage
} = state['features/subtitles'];
return {
_conference: state['features/base/conference'].conference,
_displaySubtitles,
_fileRecordingSession:
getActiveSession(state, JitsiRecordingConstants.mode.FILE),
_localRecording: LocalRecordingManager.isRecordingLocally()
_localRecording: LocalRecordingManager.isRecordingLocally(),
_subtitlesLanguage
};
}

View File

@@ -36,9 +36,6 @@ interface ILocalRecordingManager {
const getMimeType = (): string => {
const possibleTypes = [
'video/mp4;codecs=h264',
'video/webm;codecs=h264',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8'
];
@@ -164,7 +161,11 @@ const LocalRecordingManager: ILocalRecordingManager = {
this.audioContext = undefined;
this.audioDestination = undefined;
this.totalSize = MAX_SIZE;
setTimeout(() => this.saveRecording(this.recordingData, this.getFilename()), 1000);
setTimeout(() => {
if (this.recordingData.length > 0) {
this.saveRecording(this.recordingData, this.getFilename());
}
}, 1000);
}
},

View File

@@ -51,7 +51,9 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
* @returns {React$Component}
*/
_renderAdvancedOptions() {
if (!this._canStartTranscribing()) {
const { selectedRecordingService } = this.props;
if (selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE || !this._canStartTranscribing()) {
return null;
}

View File

@@ -59,7 +59,9 @@ class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
* @returns {React$Component}
*/
_renderAdvancedOptions() {
if (!this._canStartTranscribing()) {
const { selectedRecordingService } = this.props;
if (selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE || !this._canStartTranscribing()) {
return null;
}

View File

@@ -4,6 +4,7 @@ import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { getSessionStatusToShow } from '../../functions';
interface IProps extends AbstractProps {
@@ -90,7 +91,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
const { mode } = ownProps;
return {
_isTranscribing: state['features/transcribing'].isTranscribing,
_isTranscribing: isRecorderTranscriptionsRunning(state),
_status: getSessionStatusToShow(state, mode)
};
}

View File

@@ -40,11 +40,11 @@ class RecordingLabel extends AbstractRecordingLabel {
_renderLabel() {
const { _isTranscribing, _status, classes, mode, t } = this.props;
const isRecording = mode === JitsiRecordingConstants.mode.FILE;
const icon = isRecording || _isTranscribing ? IconRecord : IconSites;
const icon = isRecording ? IconRecord : IconSites;
let content;
if (_status === JitsiRecordingConstants.status.ON) {
content = t(isRecording || _isTranscribing ? 'videoStatus.recording' : 'videoStatus.streaming');
content = t(isRecording ? 'videoStatus.recording' : 'videoStatus.streaming');
if (_isTranscribing) {
content += ` \u00B7 ${t('transcribing.labelToolTip')}`;

View File

@@ -60,3 +60,5 @@ export const RECORDING_STATUS_PRIORITIES = [
];
export const START_RECORDING_NOTIFICATION_ID = 'START_RECORDING_NOTIFICATION_ID';
export const RECORDING_METADATA_ID = 'recording';

View File

@@ -14,6 +14,7 @@ import { registerSound, unregisterSound } from '../base/sounds/actions';
import { isInBreakoutRoom } from '../breakout-rooms/functions';
import { isEnabled as isDropboxEnabled } from '../dropbox/functions';
import { extractFqnFromPath } from '../dynamic-branding/functions.any';
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../transcribing/functions';
import LocalRecordingManager from './components/Recording/LocalRecordingManager';
import {
@@ -139,9 +140,8 @@ export function getSessionStatusToShow(state: IReduxState, mode: string): string
}
}
}
if ((!Array.isArray(recordingSessions) || recordingSessions.length === 0)
&& mode === JitsiRecordingConstants.mode.FILE
&& (LocalRecordingManager.isRecordingLocally() || isRemoteParticipantRecordingLocally(state))) {
if (!status && mode === JitsiRecordingConstants.mode.FILE
&& (LocalRecordingManager.isRecordingLocally() || isRemoteParticipantRecordingLocally(state))) {
status = JitsiRecordingConstants.status.ON;
}
@@ -158,6 +158,16 @@ export function supportsLocalRecording() {
&& navigator.product !== 'ReactNative';
}
/**
* Returns true if there is a cloud recording running.
*
* @param {IReduxState} state - The redux state to search in.
* @returns {boolean}
*/
export function isCloudRecordingRunning(state: IReduxState) {
return Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE));
}
/**
* Returns true if there is a recording session running.
*
@@ -165,15 +175,30 @@ export function supportsLocalRecording() {
* @returns {boolean}
*/
export function isRecordingRunning(state: IReduxState) {
const { isTranscribing } = state['features/transcribing'];
return (
Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE))
isCloudRecordingRunning(state)
|| LocalRecordingManager.isRecordingLocally()
|| isTranscribing
);
}
/**
* Returns true if the participant can stop recording.
*
* @param {Object} state - The redux state to search in.
* @returns {boolean}
*/
export function canStopRecording(state: IReduxState) {
if (LocalRecordingManager.isRecordingLocally()) {
return true;
}
if (isCloudRecordingRunning(state) || isRecorderTranscriptionsRunning(state)) {
return isLocalParticipantModerator(state) && isJwtFeatureEnabled(state, 'recording', true);
}
return false;
}
/**
* Returns whether the transcription should start automatically when recording starts.
*
@@ -183,7 +208,7 @@ export function isRecordingRunning(state: IReduxState) {
export function shouldAutoTranscribeOnRecord(state: IReduxState) {
const { transcription } = state['features/base/config'];
return transcription?.autoTranscribeOnRecord ?? true;
return (transcription?.autoTranscribeOnRecord ?? true) && canAddTranscriber(state);
}
/**
@@ -323,7 +348,7 @@ export async function sendMeetingHighlight(state: IReduxState) {
* @param {Object} state - Redux state.
* @returns {boolean}
*/
function isRemoteParticipantRecordingLocally(state: IReduxState) {
export function isRemoteParticipantRecordingLocally(state: IReduxState) {
const participants = getRemoteParticipants(state);
// eslint-disable-next-line prefer-const

View File

@@ -9,8 +9,10 @@ import JitsiMeetJS, {
JitsiRecordingConstants
} from '../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../base/media/constants';
import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes';
import { updateLocalRecordingStatus } from '../base/participants/actions';
import { getParticipantDisplayName } from '../base/participants/functions';
import { PARTICIPANT_ROLE } from '../base/participants/constants';
import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import {
@@ -20,6 +22,7 @@ import {
import { TRACK_ADDED } from '../base/tracks/actionTypes';
import { hideNotification, showErrorNotification, showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { isRecorderTranscriptionsRunning } from '../transcribing/functions';
import { RECORDING_SESSION_UPDATED, START_LOCAL_RECORDING, STOP_LOCAL_RECORDING } from './actionTypes';
import {
@@ -29,6 +32,7 @@ import {
showRecordingError,
showRecordingLimitNotification,
showRecordingWarning,
showStartRecordingNotification,
showStartedRecordingNotification,
showStoppedRecordingNotification,
updateRecordingSessionData
@@ -175,6 +179,8 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
}
case RECORDING_SESSION_UPDATED: {
const state = getState();
// When in recorder mode no notifications are shown
// or extra sounds are also not desired
// but we want to indicate those in case of sip gateway
@@ -182,22 +188,21 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
iAmRecorder,
iAmSipGateway,
recordingLimit
} = getState()['features/base/config'];
} = state['features/base/config'];
if (iAmRecorder && !iAmSipGateway) {
break;
}
const updatedSessionData
= getSessionById(getState(), action.sessionData.id);
= getSessionById(state, action.sessionData.id);
const { initiator, mode = '', terminator } = updatedSessionData ?? {};
const { PENDING, OFF, ON } = JitsiRecordingConstants.status;
if (updatedSessionData?.status === PENDING
&& (!oldSessionData || oldSessionData.status !== PENDING)) {
if (updatedSessionData?.status === PENDING && oldSessionData?.status !== PENDING) {
dispatch(showPendingRecordingNotification(mode));
dispatch(hideNotification(START_RECORDING_NOTIFICATION_ID));
} else if (updatedSessionData?.status !== PENDING) {
} else {
dispatch(hidePendingRecordingNotification(mode));
if (updatedSessionData?.status === ON) {
@@ -215,12 +220,13 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
dispatch(showStartedRecordingNotification(mode, initiator, action.sessionData.id));
}
}
if (!oldSessionData || oldSessionData.status !== ON) {
if (oldSessionData?.status !== ON) {
sendAnalytics(createRecordingEvent('start', mode));
let soundID;
if (mode === JitsiRecordingConstants.mode.FILE) {
if (mode === JitsiRecordingConstants.mode.FILE && !isRecorderTranscriptionsRunning(state)) {
soundID = RECORDING_ON_SOUND_ID;
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
soundID = LIVE_STREAMING_ON_SOUND_ID;
@@ -234,12 +240,11 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
APP.API.notifyRecordingStatusChanged(true, mode);
}
}
} else if (updatedSessionData?.status === OFF
&& (!oldSessionData || oldSessionData.status !== OFF)) {
} else if (updatedSessionData?.status === OFF && oldSessionData?.status !== OFF) {
if (terminator) {
dispatch(
showStoppedRecordingNotification(
mode, getParticipantDisplayName(getState, getResourceId(terminator))));
mode, getParticipantDisplayName(state, getResourceId(terminator))));
}
let duration = 0, soundOff, soundOn;
@@ -250,7 +255,7 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
}
sendAnalytics(createRecordingEvent('stop', mode, duration));
if (mode === JitsiRecordingConstants.mode.FILE) {
if (mode === JitsiRecordingConstants.mode.FILE && !isRecorderTranscriptionsRunning(state)) {
soundOff = RECORDING_OFF_SOUND_ID;
soundOn = RECORDING_ON_SOUND_ID;
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
@@ -281,6 +286,21 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => async action =>
}
break;
}
case PARTICIPANT_UPDATED: {
const { id, role } = action.participant;
const state = getState();
const localParticipant = getLocalParticipant(state);
if (localParticipant?.id !== id) {
return next(action);
}
if (role === PARTICIPANT_ROLE.MODERATOR) {
dispatch(showStartRecordingNotification());
}
return next(action);
}
}
return result;

View File

@@ -6,6 +6,7 @@ import {
SET_MEETING_HIGHLIGHT_BUTTON_STATE,
SET_PENDING_RECORDING_NOTIFICATION_UID,
SET_SELECTED_RECORDING_SERVICE,
SET_START_RECORDING_NOTIFICATION_SHOWN,
SET_STREAM_KEY
} from './actionTypes';
@@ -35,6 +36,7 @@ export interface IRecordingState {
selectedRecordingService: string;
sessionDatas: Array<ISessionData>;
streamKey?: string;
wasStartRecordingSuggested?: boolean;
}
/**
@@ -94,6 +96,12 @@ ReducerRegistry.register<IRecordingState>(STORE_NAME,
disableHighlightMeetingMoment: action.disabled
};
case SET_START_RECORDING_NOTIFICATION_SHOWN:
return {
...state,
wasStartRecordingSuggested: true
};
default:
return state;
}

View File

@@ -1,8 +1,7 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import { toState } from '../base/redux/functions';
import { getActiveSession } from '../recording/functions';
import { isCloudRecordingRunning } from '../recording/functions';
import { isScreenVideoShared } from '../screen-share/functions';
import ScreenshotCaptureSummary from './ScreenshotCaptureSummary';
@@ -48,7 +47,7 @@ export function isScreenshotCaptureEnabled(state: IReduxState, checkSharing?: bo
}
// Feature enabled only when recording is also on.
return Boolean(getActiveSession(state, JitsiRecordingConstants.mode.FILE));
return isCloudRecordingRunning(state);
}
return true;

View File

@@ -1,16 +1,3 @@
/**
* The type of (redux) action which indicates that an endpoint message
* sent by another participant to the data channel is received.
*
* {
* type: ENDPOINT_MESSAGE_RECEIVED,
* participant: Object,
* json: Object
* }
*/
export const ENDPOINT_MESSAGE_RECEIVED = 'ENDPOINT_MESSAGE_RECEIVED';
/**
* The type of (redux) action which indicates that an existing transcript
* has to be removed from the state.

View File

@@ -1,32 +1,12 @@
import { DEFAULT_LANGUAGE } from '../base/i18n/i18next';
import {
ENDPOINT_MESSAGE_RECEIVED,
REMOVE_TRANSCRIPT_MESSAGE,
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES,
UPDATE_TRANSCRIPT_MESSAGE
} from './actionTypes';
/**
* Signals that a participant sent an endpoint message on the data channel.
*
* @param {Object} participant - The participant details sending the message.
* @param {Object} json - The json carried by the endpoint message.
* @returns {{
* type: ENDPOINT_MESSAGE_RECEIVED,
* participant: Object,
* json: Object
* }}
*/
export function endpointMessageReceived(participant: Object, json: Object) {
return {
type: ENDPOINT_MESSAGE_RECEIVED,
participant,
json
};
}
/**
* Signals that a transcript has to be removed from the state.
*

View File

@@ -4,7 +4,7 @@ import { IReduxState } from '../../app/types';
import { MEET_FEATURES } from '../../base/jwt/constants';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { maybeShowPremiumFeatureDialog } from '../../jaas/actions';
import { canStartTranscribing } from '../functions';
import { canStartSubtitles } from '../functions';
export interface IAbstractProps extends AbstractButtonProps {
@@ -106,7 +106,7 @@ export function _abstractMapStateToProps(state: IReduxState, ownProps: IAbstract
// if the participant is moderator, it can enable transcriptions and if
// transcriptions are already started for the meeting, guests can just show them
const { visible = canStartTranscribing(state) } = ownProps;
const { visible = canStartSubtitles(state) } = ownProps;
return {
_requestingSubtitles,

View File

@@ -1,15 +1,12 @@
import { IReduxState } from '../app/types';
import { isLocalParticipantModerator } from '../base/participants/functions';
import { canAddTranscriber, isTranscribing } from '../transcribing/functions';
/**
* Checks whether the participant can start the transcription.
* Checks whether the participant can start the subtitles.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - True if the participant can start the transcription.
* @returns {boolean} - True if the participant can start the subtitles.
*/
export function canStartTranscribing(state: IReduxState) {
const { transcription } = state['features/base/config'];
const { isTranscribing } = state['features/transcribing'];
return Boolean(transcription?.enabled && (isLocalParticipantModerator(state) || isTranscribing));
export function canStartSubtitles(state: IReduxState) {
return canAddTranscriber(state) || isTranscribing(state);
}

View File

@@ -1,10 +1,10 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import {
ENDPOINT_MESSAGE_RECEIVED,
SET_REQUESTING_SUBTITLES,
TOGGLE_REQUESTING_SUBTITLES
} from './actionTypes';
@@ -90,7 +90,7 @@ MiddlewareRegistry.register(store => next => action => {
* @returns {Object} The value returned by {@code next(action)}.
*/
function _endpointMessageReceived({ dispatch, getState }: IStore, next: Function, action: AnyAction) {
const { json } = action;
const { data: json } = action;
if (![ JSON_TYPE_TRANSCRIPTION_RESULT, JSON_TYPE_TRANSLATION_RESULT ].includes(json?.type)) {
return next(action);

View File

@@ -9,7 +9,7 @@ import {
* Default State for 'features/transcription' feature.
*/
const defaultState = {
_displaySubtitles: true,
_displaySubtitles: false,
_transcriptMessages: new Map(),
_requestingSubtitles: false,
_language: null

View File

@@ -23,6 +23,7 @@ import { isSpeakerStatsDisabled } from '../../../speaker-stats/functions';
import ClosedCaptionButton from '../../../subtitles/components/native/ClosedCaptionButton';
import TileViewButton from '../../../video-layout/components/TileViewButton';
import styles from '../../../video-menu/components/native/styles';
import WhiteboardButton from '../../../whiteboard/components/native/WhiteboardButton';
import { getMovableButtons } from '../../functions.native';
import AudioOnlyButton from './AudioOnlyButton';
@@ -156,6 +157,7 @@ class OverflowMenu extends PureComponent<IProps, IState> {
<RecordButton { ...buttonProps } />
<LiveStreamButton { ...buttonProps } />
<LinkToSalesforceButton { ...buttonProps } />
<WhiteboardButton { ...buttonProps } />
{/* @ts-ignore */}
<Divider style = { styles.divider as ViewStyle } />
<SharedVideoButton { ...buttonProps } />

View File

@@ -1,6 +1,9 @@
import i18next from 'i18next';
import { IReduxState } from '../app/types';
import { IConfig } from '../base/config/configType';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { isLocalParticipantModerator } from '../base/participants/functions';
import JITSI_TO_BCP47_MAP from './jitsi-bcp47-map.json';
import logger from './logger';
@@ -42,3 +45,47 @@ export function determineTranscriptionLanguage(config: IConfig) {
return safeBCP47Locale;
}
/**
* Returns whether there is transcribing.
*
* @param {IReduxState} state - The redux state to search in.
* @returns {boolean}
*/
export function isTranscribing(state: IReduxState) {
return state['features/transcribing'].isTranscribing;
}
/**
* Returns true if there is a recorder transcription session running.
* NOTE: If only the subtitles are running this function will return false.
*
* @param {Object} state - The redux state to search in.
* @returns {boolean}
*/
export function isRecorderTranscriptionsRunning(state: IReduxState) {
const { metadata } = state['features/base/conference'];
return isTranscribing(state) && Boolean(metadata?.recording?.isTranscribingEnabled);
}
/**
* Checks whether the participant can start the transcription.
*
* @param {IReduxState} state - The redux state.
* @returns {boolean} - True if the participant can start the transcription.
*/
export function canAddTranscriber(state: IReduxState) {
const { transcription } = state['features/base/config'];
const isJwtTranscribingEnabled = isJwtFeatureEnabled(state, 'transcription', isLocalParticipantModerator(state));
if (!transcription?.enabled) {
return false;
}
if (isJwtTranscribingEnabled) {
return true;
}
return false;
}

View File

@@ -1,30 +1,16 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import {
HIDDEN_PARTICIPANT_JOINED,
HIDDEN_PARTICIPANT_LEFT,
PARTICIPANT_UPDATED
} from '../base/participants/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { playSound } from '../base/sounds/actions';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import {
RECORDING_OFF_SOUND_ID,
RECORDING_ON_SOUND_ID
} from '../recording/constants';
import {
_TRANSCRIBER_JOINED,
_TRANSCRIBER_LEFT
} from './actionTypes';
import {
potentialTranscriberJoined,
transcriberJoined,
transcriberLeft
} from './actions';
import './subscriber';
const TRANSCRIBER_DISPLAY_NAME = 'Transcriber';
@@ -42,16 +28,6 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
} = getState()['features/transcribing'];
switch (action.type) {
case _TRANSCRIBER_JOINED:
notifyTranscribingStatusChanged(true);
maybeEmitRecordingNotification(dispatch, getState, true);
break;
case _TRANSCRIBER_LEFT:
notifyTranscribingStatusChanged(false);
maybeEmitRecordingNotification(dispatch, getState, false);
break;
case HIDDEN_PARTICIPANT_JOINED:
if (action.displayName === TRANSCRIBER_DISPLAY_NAME) {
dispatch(transcriberJoined(action.id));
@@ -79,44 +55,3 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
return next(action);
});
/**
* Emit a recording started / stopped notification if the transcription started / stopped. Only
* if there is no recording in progress.
*
* @param {Dispatch} dispatch - The Redux dispatch function.
* @param {Function} getState - The Redux state.
* @param {boolean} on - Whether the transcription is on or not.
*
* @returns {void}
*/
function maybeEmitRecordingNotification(dispatch: IStore['dispatch'], getState: IStore['getState'], on: boolean) {
const state = getState();
const { sessionDatas } = state['features/recording'];
const { status: statusConstants } = JitsiRecordingConstants;
if (sessionDatas.some(sd => sd.mode !== statusConstants.OFF)) {
// If a recording is still ongoing, don't send any notification.
return;
}
batch(() => {
dispatch(showNotification({
descriptionKey: on ? 'recording.on' : 'recording.off',
titleKey: 'dialog.recording'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
dispatch(playSound(on ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
});
}
/**
* Notify external application (if API is enabled) that transcribing has started or stopped.
*
* @param {boolean} on - True if transcribing is on, false otherwise.
* @returns {void}
*/
function notifyTranscribingStatusChanged(on: boolean) {
if (typeof APP !== 'undefined') {
APP.API.notifyTranscribingStatusChanged(on);
}
}

View File

@@ -0,0 +1,68 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { playSound } from '../base/sounds/actions';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../recording/constants';
import { isRecorderTranscriptionsRunning } from './functions';
/**
* Listens for large video participant ID changes.
*/
StateListenerRegistry.register(
/* selector */ isRecorderTranscriptionsRunning,
/* listener */ (isRecorderTranscriptionsRunningValue, { getState, dispatch }) => {
if (isRecorderTranscriptionsRunningValue) {
notifyTranscribingStatusChanged(true);
maybeEmitRecordingNotification(dispatch, getState, true);
} else {
notifyTranscribingStatusChanged(false);
maybeEmitRecordingNotification(dispatch, getState, false);
}
}
);
/**
* Emit a recording started / stopped notification if the transcription started / stopped. Only
* if there is no recording in progress.
*
* @param {Dispatch} dispatch - The Redux dispatch function.
* @param {Function} getState - The Redux state.
* @param {boolean} on - Whether the transcription is on or not.
*
* @returns {void}
*/
function maybeEmitRecordingNotification(dispatch: IStore['dispatch'], getState: IStore['getState'], on: boolean) {
const state = getState();
const { sessionDatas } = state['features/recording'];
const { mode: modeConstants, status: statusConstants } = JitsiRecordingConstants;
if (sessionDatas.some(sd => sd.mode === modeConstants.FILE && sd.status === statusConstants.ON)) {
// If a recording is still ongoing, don't send any notification.
return;
}
batch(() => {
dispatch(showNotification({
descriptionKey: on ? 'recording.on' : 'recording.off',
titleKey: 'dialog.recording'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
dispatch(playSound(on ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
});
}
/**
* Notify external application (if API is enabled) that transcribing has started or stopped.
*
* @param {boolean} on - True if transcribing is on, false otherwise.
* @returns {void}
*/
function notifyTranscribingStatusChanged(on: boolean) {
if (typeof APP !== 'undefined') {
APP.API.notifyTranscribingStatusChanged(on);
}
}

View File

@@ -2,7 +2,11 @@ import i18n from 'i18next';
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { CONFERENCE_JOINED, CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes';
import {
CONFERENCE_JOINED,
CONFERENCE_JOIN_IN_PROGRESS,
ENDPOINT_MESSAGE_RECEIVED
} from '../base/conference/actionTypes';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { raiseHand } from '../base/participants/actions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
@@ -70,16 +74,17 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
dispatch(raiseHand(false));
});
conference.on(JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,
(user: any, data: any) => {
if (data?.action === 'promotion-response' && data.approved) {
const request = getPromotionRequests(getState())
.find(r => r.from === data.id);
break;
}
case ENDPOINT_MESSAGE_RECEIVED: {
const { data } = action;
request && dispatch(clearPromotionRequest(request));
}
});
if (data?.action === 'promotion-response' && data.approved) {
const request = getPromotionRequests(getState())
.find(r => r.from === data.id);
request && dispatch(clearPromotionRequest(request));
}
break;
}
}

View File

@@ -1,31 +1,68 @@
import { IStore } from '../app/types';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { setWhiteboardOpen } from './actions';
import { isWhiteboardAllowed, isWhiteboardOpen, isWhiteboardVisible } from './functions';
import { WhiteboardStatus } from './types';
import {
RESET_WHITEBOARD,
SETUP_WHITEBOARD,
SET_WHITEBOARD_OPEN
} from './actionTypes';
import { IWhiteboardAction } from './reducer';
/**
* API to toggle the whiteboard.
* Configures the whiteboard collaboration details.
*
* @param {Object} payload - The whiteboard settings.
* @returns {{
* type: SETUP_WHITEBOARD,
* collabDetails: { roomId: string, roomKey: string }
* }}
*/
export const setupWhiteboard = ({ collabDetails }: {
collabDetails: { roomId: string; roomKey: string; };
}): IWhiteboardAction => {
return {
type: SETUP_WHITEBOARD,
collabDetails
};
};
/**
* Cleans up the whiteboard collaboration settings.
* To be used only on native for cleanup in between conferences.
*
* @returns {{
* type: RESET_WHITEBOARD
* }}
*/
export const resetWhiteboard = (): IWhiteboardAction => {
return { type: RESET_WHITEBOARD };
};
/**
* Sets the whiteboard visibility status.
*
* @param {boolean} isOpen - The whiteboard visibility flag.
* @returns {{
* type: SET_WHITEBOARD_OPEN,
* isOpen
* }}
*/
export const setWhiteboardOpen = (isOpen: boolean): IWhiteboardAction => {
return {
type: SET_WHITEBOARD_OPEN,
isOpen
};
};
/**
* Shows a warning notification about the whiteboard user limit.
*
* @returns {Function}
*/
export function toggleWhiteboard() {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isAllowed = isWhiteboardAllowed(state);
const isOpen = isWhiteboardOpen(state);
if (isAllowed) {
if (isOpen && !isWhiteboardVisible(state)) {
dispatch(setWhiteboardOpen(true));
} else if (isOpen && isWhiteboardVisible(state)) {
dispatch(setWhiteboardOpen(false));
} else if (!isOpen) {
dispatch(setWhiteboardOpen(true));
}
} else if (typeof APP !== 'undefined') {
APP.API.notifyWhiteboardStatusChanged(WhiteboardStatus.FORBIDDEN);
}
};
}
export const notifyWhiteboardLimit = () => (dispatch: IStore['dispatch']) => {
dispatch(showWarningNotification({
titleKey: 'notify.whiteboardLimitTitle',
descriptionKey: 'notify.whiteboardLimitDescription'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
};

View File

@@ -0,0 +1,23 @@
import { createRestrictWhiteboardEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { navigateRoot } from '../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { resetWhiteboard } from './actions.any';
export * from './actions.any';
/**
* Restricts the whiteboard usage.
*
* @param {boolean} shouldCloseWhiteboard - Whether to dismiss the whiteboard.
* @returns {Function}
*/
export const restrictWhiteboard = (shouldCloseWhiteboard = true) => (dispatch: IStore['dispatch']) => {
if (shouldCloseWhiteboard) {
navigateRoot(screen.conference.root);
}
dispatch(resetWhiteboard());
sendAnalytics(createRestrictWhiteboardEvent());
};

View File

@@ -1,82 +0,0 @@
import { createRestrictWhiteboardEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import {
RESET_WHITEBOARD,
SETUP_WHITEBOARD,
SET_WHITEBOARD_OPEN
} from './actionTypes';
import { IWhiteboardAction } from './reducer';
/**
* Configures the whiteboard collaboration details.
*
* @param {Object} payload - The whiteboard settings.
* @returns {{
* type: SETUP_WHITEBOARD,
* collabDetails: { roomId: string, roomKey: string }
* }}
*/
export const setupWhiteboard = ({ collabDetails }: {
collabDetails: { roomId: string; roomKey: string; };
}): IWhiteboardAction => {
return {
type: SETUP_WHITEBOARD,
collabDetails
};
};
/**
* Cleans up the whiteboard collaboration settings.
* To be used only on native for cleanup in between conferences.
*
* @returns {{
* type: RESET_WHITEBOARD
* }}
*/
export const resetWhiteboard = (): IWhiteboardAction => {
return { type: RESET_WHITEBOARD };
};
/**
* Sets the whiteboard visibility status.
*
* @param {boolean} isOpen - The whiteboard visibility flag.
* @returns {{
* type: SET_WHITEBOARD_OPEN,
* isOpen
* }}
*/
export const setWhiteboardOpen = (isOpen: boolean): IWhiteboardAction => {
return {
type: SET_WHITEBOARD_OPEN,
isOpen
};
};
/**
* Shows a warning notification about the whiteboard user limit.
*
* @returns {Function}
*/
export const notifyWhiteboardLimit = () => (dispatch: IStore['dispatch']) => {
dispatch(showWarningNotification({
titleKey: 'notify.whiteboardLimitTitle',
descriptionKey: 'notify.whiteboardLimitDescription'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
};
/**
* Restricts the whiteboard usage.
*
* @param {boolean} shouldCloseWhiteboard - Whether to dismiss the whiteboard participant.
* @returns {Function}
*/
export const restrictWhiteboard = (shouldCloseWhiteboard = true) => (dispatch: IStore['dispatch']) => {
shouldCloseWhiteboard && dispatch(setWhiteboardOpen(false));
dispatch(resetWhiteboard());
sendAnalytics(createRestrictWhiteboardEvent());
};

View File

@@ -0,0 +1,48 @@
import { createRestrictWhiteboardEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { resetWhiteboard, setWhiteboardOpen } from './actions.any';
import { isWhiteboardAllowed, isWhiteboardOpen, isWhiteboardVisible } from './functions';
import { WhiteboardStatus } from './types';
export * from './actions.any';
/**
* API to toggle the whiteboard.
*
* @returns {Function}
*/
export function toggleWhiteboard() {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isAllowed = isWhiteboardAllowed(state);
const isOpen = isWhiteboardOpen(state);
if (isAllowed) {
if (isOpen && !isWhiteboardVisible(state)) {
dispatch(setWhiteboardOpen(true));
} else if (isOpen && isWhiteboardVisible(state)) {
dispatch(setWhiteboardOpen(false));
} else if (!isOpen) {
dispatch(setWhiteboardOpen(true));
}
} else if (typeof APP !== 'undefined') {
APP.API.notifyWhiteboardStatusChanged(WhiteboardStatus.FORBIDDEN);
}
};
}
/**
* Restricts the whiteboard usage.
*
* @param {boolean} shouldCloseWhiteboard - Whether to dismiss the whiteboard.
* @returns {Function}
*/
export const restrictWhiteboard = (shouldCloseWhiteboard = true) => (dispatch: IStore['dispatch']) => {
if (shouldCloseWhiteboard) {
dispatch(setWhiteboardOpen(false));
}
dispatch(resetWhiteboard());
sendAnalytics(createRestrictWhiteboardEvent());
};

View File

@@ -0,0 +1,220 @@
import { Route } from '@react-navigation/native';
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { View, ViewStyle } from 'react-native';
import { WebView } from 'react-native-webview';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import { safeDecodeURIComponent } from '../../../base/util/uri';
import { setupWhiteboard } from '../../actions.any';
import { WHITEBOARD_ID } from '../../constants';
import { getCollabServerUrl, getWhiteboardInfoForURIString } from '../../functions';
import WhiteboardErrorDialog from './WhiteboardErrorDialog';
import styles, { INDICATOR_COLOR } from './styles';
interface IProps extends WithTranslation {
/**
* The whiteboard collab server url.
*/
collabServerUrl?: string;
/**
* The current Jitsi conference.
*/
conference?: IJitsiConference;
/**
* Redux store dispatch method.
*/
dispatch: IStore['dispatch'];
/**
* Window location href.
*/
locationHref: string;
/**
* Default prop for navigating between screen components(React Navigation).
*/
navigation: any;
/**
* Default prop for navigating between screen components(React Navigation).
*/
route: Route<'', {
collabDetails: { roomId: string; roomKey: string; };
collabServerUrl: string;
localParticipantName: string;
}>;
}
/**
* Implements a React native component that displays the whiteboard page for a specific room.
*/
class Whiteboard extends PureComponent<IProps> {
/**
* Initializes a new instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onError = this._onError.bind(this);
this._onNavigate = this._onNavigate.bind(this);
this._onMessage = this._onMessage.bind(this);
this._renderLoading = this._renderLoading.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after mounting occurs.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerTitle: t('whiteboard.screenTitle')
});
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
render() {
const { locationHref, route } = this.props;
const collabServerUrl = safeDecodeURIComponent(route.params?.collabServerUrl);
const localParticipantName = safeDecodeURIComponent(route.params?.localParticipantName);
const collabDetails = route.params?.collabDetails;
const uri = getWhiteboardInfoForURIString(
locationHref,
collabServerUrl,
collabDetails,
localParticipantName
) ?? '';
return (
<JitsiScreen
safeAreaInsets = { [ 'bottom', 'left', 'right' ] }
style = { styles.backDrop }>
<WebView
incognito = { true }
javaScriptEnabled = { true }
nestedScrollEnabled = { true }
onError = { this._onError }
onMessage = { this._onMessage }
onShouldStartLoadWithRequest = { this._onNavigate }
renderLoading = { this._renderLoading }
scrollEnabled = { true }
setSupportMultipleWindows = { false }
source = {{ uri }}
startInLoadingState = { true }
style = { styles.webView } />
</JitsiScreen>
);
}
/**
* Callback to handle the error if the page fails to load.
*
* @returns {void}
*/
_onError() {
this.props.dispatch(openDialog(WhiteboardErrorDialog));
}
/**
* Callback to intercept navigation inside the webview and make the native app handle the whiteboard requests.
*
* NOTE: We don't navigate to anywhere else from that view.
*
* @param {any} request - The request object.
* @returns {boolean}
*/
_onNavigate(request: { url: string; }) {
const { url } = request;
const { locationHref, route } = this.props;
const collabServerUrl = route.params?.collabServerUrl;
const collabDetails = route.params?.collabDetails;
const localParticipantName = route.params?.localParticipantName;
return url === getWhiteboardInfoForURIString(
locationHref,
collabServerUrl,
collabDetails,
localParticipantName
);
}
/**
* Callback to handle the message events.
*
* @param {any} event - The event.
* @returns {void}
*/
_onMessage(event: any) {
const { collabServerUrl, conference } = this.props;
const collabDetails = JSON.parse(event.nativeEvent.data);
if (collabDetails?.roomId && collabDetails?.roomKey) {
this.props.dispatch(setupWhiteboard({ collabDetails }));
// Broadcast the collab details.
conference?.getMetadataHandler().setMetadata(WHITEBOARD_ID, {
collabServerUrl,
collabDetails
});
}
}
/**
* Renders the loading indicator.
*
* @returns {React$Component<any>}
*/
_renderLoading() {
return (
<View style = { styles.indicatorWrapper as ViewStyle }>
<LoadingIndicator
color = { INDICATOR_COLOR }
size = 'large' />
</View>
);
}
}
/**
* Maps (parts of) the redux state to the associated
* {@code WaitForOwnerDialog}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
const { locationURL } = state['features/base/connection'];
const { href = '' } = locationURL ?? {};
return {
conference: getCurrentConference(state),
collabServerUrl: getCollabServerUrl(state),
locationHref: href
};
}
export default translate(connect(mapStateToProps)(Whiteboard));

View File

@@ -0,0 +1,43 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconWhiteboard } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { setWhiteboardOpen } from '../../actions.any';
import { isWhiteboardButtonVisible } from '../../functions';
/**
* Component that renders a toolbar button for the whiteboard.
*/
class WhiteboardButton extends AbstractButton<AbstractButtonProps> {
accessibilityLabel = 'toolbar.accessibilityLabel.showWhiteboard';
icon = IconWhiteboard;
label = 'toolbar.showWhiteboard';
tooltip = 'toolbar.showWhiteboard';
/**
* Handles clicking / pressing the button, and opens the whiteboard view.
*
* @private
* @returns {void}
*/
_handleClick() {
this.props.dispatch(setWhiteboardOpen(true));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
visible: isWhiteboardButtonVisible(state)
};
}
export default translate(connect(_mapStateToProps)(WhiteboardButton));

View File

@@ -0,0 +1,15 @@
import React from 'react';
import AlertDialog from '../../../base/dialog/components/native/AlertDialog';
/**
* Dialog to inform the user that we couldn't load the whiteboard.
*
* @returns {JSX.Element}
*/
const WhiteboardErrorDialog = () => (
<AlertDialog
contentKey = 'info.whiteboardError' />
);
export default WhiteboardErrorDialog;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, TextStyle } from 'react-native';
import { useSelector } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import Link from '../../../base/react/components/native/Link';
import { getWhiteboardConfig } from '../../functions';
import styles from './styles';
/**
* Component that renders the whiteboard user limit dialog.
*
* @returns {JSX.Element}
*/
const WhiteboardLimitDialog = () => {
const { t } = useTranslation();
const { limitUrl } = useSelector(getWhiteboardConfig);
return (
<ConfirmDialog
cancelLabel = { 'dialog.Ok' }
descriptionKey = { 'dialog.whiteboardLimitContent' }
isConfirmHidden = { true }
title = { 'dialog.whiteboardLimitTitle' }>
{limitUrl && (
<Text style = { styles.limitUrlText as TextStyle }>
{` ${t('dialog.whiteboardLimitReference')}
`}
<Link
style = { styles.limitUrl }
url = { limitUrl }>
{t('dialog.whiteboardLimitReferenceUrl')}
</Link>
.
</Text>
)}
</ConfirmDialog>
);
};
export default WhiteboardLimitDialog;

View File

@@ -0,0 +1,37 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export const INDICATOR_COLOR = BaseTheme.palette.ui07;
const WV_BACKGROUND = BaseTheme.palette.ui03;
export default {
backDrop: {
backgroundColor: WV_BACKGROUND,
flex: 1
},
indicatorWrapper: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui10,
height: '100%',
justifyContent: 'center'
},
webView: {
backgroundColor: WV_BACKGROUND,
flex: 1
},
limitUrlText: {
alignItems: 'center',
display: 'flex',
marginBottom: BaseTheme.spacing[2],
textAlign: 'center'
},
limitUrl: {
color: BaseTheme.palette.link01,
fontWeight: 'bold'
}
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
/**
* The type of the React {@code Component} props of {@link NoWhiteboardError}.
*/
interface IProps {
/**
* Additional CSS classnames to append to the root of the component.
*/
className?: string;
}
const NoWhiteboardError = ({ className }: IProps) => {
const { t } = useTranslation();
return (
<div className = { className } >
{t('info.noWhiteboard')}
</div>
);
};
export default NoWhiteboardError;

View File

@@ -1,5 +1,6 @@
import { ExcalidrawApp } from '@jitsi/excalidraw';
import clsx from 'clsx';
import i18next from 'i18next';
import React, { useCallback, useEffect, useRef } from 'react';
import { WithTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -143,6 +144,7 @@ const Whiteboard = (props: WithTranslation): JSX.Element => {
collabServerUrl = { collabServerUrl }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
// @ts-ignore
ref: excalidrawRef,

View File

@@ -0,0 +1,91 @@
import { generateCollaborationLinkData } from '@jitsi/excalidraw';
import React, { ComponentType } from 'react';
import BaseApp from '../../../base/app/components/BaseApp';
import GlobalStyles from '../../../base/ui/components/GlobalStyles.web';
import JitsiThemeProvider from '../../../base/ui/components/JitsiThemeProvider.web';
import { decodeFromBase64URL } from '../../../base/util/httpUtils';
import { parseURLParams } from '../../../base/util/parseURLParams';
import { safeDecodeURIComponent } from '../../../base/util/uri';
import logger from '../../logger';
import NoWhiteboardError from './NoWhiteboardError';
import WhiteboardWrapper from './WhiteboardWrapper';
/**
* Wrapper application for the whiteboard.
*
* @augments BaseApp
*/
export default class WhiteboardApp extends BaseApp<any> {
/**
* Navigates to {@link Whiteboard} upon mount.
*
* @returns {void}
*/
async componentDidMount() {
await super.componentDidMount();
const { state } = parseURLParams(window.location.href, true);
const decodedState = JSON.parse(decodeFromBase64URL(state));
const { collabServerUrl, localParticipantName } = decodedState;
let { roomId, roomKey } = decodedState;
if (!roomId && !roomKey) {
try {
const collabDetails = await generateCollaborationLinkData();
roomId = collabDetails.roomId;
roomKey = collabDetails.roomKey;
if (window.ReactNativeWebView) {
setTimeout(() => {
window.ReactNativeWebView.postMessage(JSON.stringify(collabDetails));
}, 0);
}
} catch (e: any) {
logger.error('Couldn\'t generate collaboration link data.', e);
}
}
super._navigate({
component: () => (
<>{
roomId && roomKey && collabServerUrl
? <WhiteboardWrapper
className = 'whiteboard'
collabDetails = {{
roomId,
roomKey
}}
collabServerUrl = { safeDecodeURIComponent(collabServerUrl) }
localParticipantName = { localParticipantName } />
: <NoWhiteboardError className = 'whiteboard' />
}</>
) });
}
/**
* Overrides the parent method to inject {@link AtlasKitThemeProvider} as
* the top most component.
*
* @override
*/
_createMainElement(component: ComponentType<any>, props: Object) {
return (
<JitsiThemeProvider>
<GlobalStyles />
{super._createMainElement(component, props)}
</JitsiThemeProvider>
);
}
/**
* Renders the platform specific dialog container.
*
* @returns {React$Element}
*/
_renderDialogContainer() {
return null;
}
}

View File

@@ -5,7 +5,7 @@ import { translate } from '../../../base/i18n/functions';
import { IconWhiteboard, IconWhiteboardHide } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { setOverflowMenuVisible } from '../../../toolbox/actions.web';
import { setWhiteboardOpen } from '../../actions';
import { setWhiteboardOpen } from '../../actions.any';
import { isWhiteboardButtonVisible, isWhiteboardVisible } from '../../functions';
interface IProps extends AbstractButtonProps {

View File

@@ -0,0 +1,70 @@
import { ExcalidrawApp } from '@jitsi/excalidraw';
import i18next from 'i18next';
import React, { useCallback, useRef } from 'react';
import { WHITEBOARD_UI_OPTIONS } from '../../constants';
/**
* Whiteboard wrapper for mobile.
*
* @returns {JSX.Element}
*/
const WhiteboardWrapper = ({
className,
collabDetails,
collabServerUrl,
localParticipantName
}: {
className?: string;
collabDetails: {
roomId: string;
roomKey: string;
};
collabServerUrl: string;
localParticipantName: string;
}) => {
const excalidrawRef = useRef<any>(null);
const excalidrawAPIRef = useRef<any>(null);
const collabAPIRef = useRef<any>(null);
const getExcalidrawAPI = useCallback(excalidrawAPI => {
if (excalidrawAPIRef.current) {
return;
}
excalidrawAPIRef.current = excalidrawAPI;
}, []);
const getCollabAPI = useCallback(collabAPI => {
if (collabAPIRef.current) {
return;
}
collabAPIRef.current = collabAPI;
collabAPIRef.current.setUsername(localParticipantName);
}, [ localParticipantName ]);
return (
<div className = { className }>
<div className = 'excalidraw-wrapper'>
<ExcalidrawApp
collabDetails = { collabDetails }
collabServerUrl = { collabServerUrl }
detectScroll = { true }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
// @ts-ignore
ref: excalidrawRef,
theme: 'light',
UIOptions: WHITEBOARD_UI_OPTIONS
}}
getCollabAPI = { getCollabAPI }
getExcalidrawAPI = { getExcalidrawAPI } />
</div>
</div>
);
};
export default WhiteboardWrapper;

View File

@@ -62,3 +62,10 @@ export const MIN_USER_LIMIT = 10;
* Whiteboard soft limit diff.
*/
export const USER_LIMIT_THRESHOLD = 5;
/**
* The pathName for the whiteboard page.
*
* @type {string}
*/
export const WHITEBOARD_PATH_NAME = 'static/whiteboard.html';

View File

@@ -5,10 +5,11 @@ import { IReduxState } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { IWhiteboardConfig } from '../base/config/configType';
import { getRemoteParticipants, isLocalParticipantModerator } from '../base/participants/functions';
import { appendURLParam } from '../base/util/uri';
import { encodeToBase64URL } from '../base/util/httpUtils';
import { appendURLHashParam, appendURLParam } from '../base/util/uri';
import { getCurrentRoomId, isInBreakoutRoom } from '../breakout-rooms/functions';
import { MIN_USER_LIMIT, USER_LIMIT_THRESHOLD, WHITEBOARD_ID } from './constants';
import { MIN_USER_LIMIT, USER_LIMIT_THRESHOLD, WHITEBOARD_ID, WHITEBOARD_PATH_NAME } from './constants';
import { IWhiteboardState } from './reducer';
const getWhiteboardState = (state: IReduxState): IWhiteboardState => state['features/whiteboard'];
@@ -35,27 +36,27 @@ export const getCollabDetails = (state: IReduxState): {
} | undefined => getWhiteboardState(state).collabDetails;
/**
* Indicates whether the whiteboard is enabled in the config.
* Indicates whether the whiteboard collaboration details are available.
*
* @param {IReduxState} state - The state from the Redux store.
* @returns {boolean}
*/
const hasCollabDetails = (state: IReduxState): boolean => Boolean(
getCollabDetails(state)?.roomId && getCollabDetails(state)?.roomKey
);
/**
* Indicates whether the whiteboard is enabled.
*
* @param {IReduxState} state - The state from the Redux store.
* @returns {boolean}
*/
export const isWhiteboardEnabled = (state: IReduxState): boolean =>
getWhiteboardConfig(state).enabled
(getWhiteboardConfig(state).enabled || hasCollabDetails(state))
&& getWhiteboardConfig(state).collabServerBaseUrl
&& getCurrentConference(state)?.getMetadataHandler()
?.isSupported();
/**
* Indicates whether the whiteboard has the collaboration
* details and is ready to be used.
*
* @param {IReduxState} state - The state from the Redux store.
* @returns {boolean}
*/
export const isWhiteboardReady = (state: IReduxState): boolean =>
isWhiteboardEnabled(state) && Boolean(getCollabDetails(state));
/**
* Indicates whether the whiteboard is open.
*
@@ -156,3 +157,50 @@ export const shouldNotifyUserLimit = (state: IReduxState): boolean => {
return participantCount + USER_LIMIT_THRESHOLD > userLimit;
};
/**
* Generates the URL for the static whiteboard page.
*
* @param {string} locationUrl - The window location href.
* @param {string} collabServerUrl - The whiteboard collaboration server url.
* @param {Object} collabDetails - The whiteboard collaboration details.
* @param {string} localParticipantName - The local participant name.
* @returns {string}
*/
export function getWhiteboardInfoForURIString(
locationUrl: any,
collabServerUrl: string,
collabDetails: { roomId: string; roomKey: string; },
localParticipantName: string
): string | undefined {
if (!collabServerUrl || !locationUrl) {
return undefined;
}
let state = {};
let url = `${locationUrl.substring(0, locationUrl.lastIndexOf('/'))}/${WHITEBOARD_PATH_NAME}`;
if (collabDetails?.roomId) {
state = {
...state,
roomId: collabDetails.roomId
};
}
if (collabDetails?.roomKey) {
state = {
...state,
roomKey: collabDetails.roomKey
};
}
state = {
...state,
collabServerUrl,
localParticipantName
};
url = appendURLHashParam(url, 'state', encodeToBase64URL(JSON.stringify(state)));
return url;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/whiteboard');

View File

@@ -0,0 +1,92 @@
import { createOpenWhiteboardEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { SET_WHITEBOARD_OPEN } from './actionTypes';
import {
notifyWhiteboardLimit,
resetWhiteboard,
restrictWhiteboard,
setWhiteboardOpen,
setupWhiteboard
} from './actions';
import { WHITEBOARD_ID } from './constants';
import {
isWhiteboardOpen,
shouldEnforceUserLimit,
shouldNotifyUserLimit
} from './functions';
MiddlewareRegistry.register((store: IStore) => next => action => {
const state = store.getState();
switch (action.type) {
case SET_WHITEBOARD_OPEN: {
const enforceUserLimit = shouldEnforceUserLimit(state);
const notifyUserLimit = shouldNotifyUserLimit(state);
if (action.isOpen && !enforceUserLimit && !notifyUserLimit) {
sendAnalytics(createOpenWhiteboardEvent());
return next(action);
}
break;
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
if (metadata?.[WHITEBOARD_ID]) {
store.dispatch(setupWhiteboard({
collabDetails: metadata[WHITEBOARD_ID].collabDetails
}));
store.dispatch(setWhiteboardOpen(true));
}
break;
}
}
return next(action);
});
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Disable the whiteboard if it's left open.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference !== previousConference) {
dispatch(resetWhiteboard());
}
});
/**
* Set up state change listener to limit whiteboard access.
*/
StateListenerRegistry.register(
state => shouldEnforceUserLimit(state),
(enforceUserLimit, { dispatch, getState }): void => {
if (isWhiteboardOpen(getState()) && enforceUserLimit) {
dispatch(restrictWhiteboard());
}
}
);
/**
* Set up state change listener to notify about whiteboard usage.
*/
StateListenerRegistry.register(
state => shouldNotifyUserLimit(state),
(notifyUserLimit, { dispatch, getState }, prevNotifyUserLimit): void => {
if (isWhiteboardOpen(getState()) && notifyUserLimit && !prevNotifyUserLimit) {
dispatch(notifyWhiteboardLimit());
}
}
);

View File

@@ -0,0 +1,80 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { hideDialog, openDialog } from '../base/dialog/actions';
import { isDialogOpen } from '../base/dialog/functions';
import { getLocalParticipant } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { navigateRoot } from '../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../mobile/navigation/routes';
import { SET_WHITEBOARD_OPEN } from './actionTypes';
import {
notifyWhiteboardLimit,
restrictWhiteboard
} from './actions';
import WhiteboardLimitDialog from './components/native/WhiteboardLimitDialog';
import {
getCollabDetails,
getCollabServerUrl,
shouldEnforceUserLimit,
shouldNotifyUserLimit
} from './functions';
import './middleware.any';
/**
* Middleware which intercepts whiteboard actions to handle changes to the related state.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register((store: IStore) => (next: Function) => async (action: AnyAction) => {
const { dispatch, getState } = store;
const state = getState();
switch (action.type) {
case SET_WHITEBOARD_OPEN: {
const enforceUserLimit = shouldEnforceUserLimit(state);
const notifyUserLimit = shouldNotifyUserLimit(state);
if (enforceUserLimit) {
dispatch(restrictWhiteboard(false));
dispatch(openDialog(WhiteboardLimitDialog));
return next(action);
}
if (action.isOpen) {
if (enforceUserLimit) {
dispatch(restrictWhiteboard());
return next(action);
}
if (notifyUserLimit) {
dispatch(notifyWhiteboardLimit());
}
if (isDialogOpen(state, WhiteboardLimitDialog)) {
dispatch(hideDialog(WhiteboardLimitDialog));
}
const collabDetails = getCollabDetails(state);
const collabServerUrl = getCollabServerUrl(state);
const localParticipantName = getLocalParticipant(state)?.name;
navigateRoot(screen.conference.whiteboard, {
collabDetails,
collabServerUrl,
localParticipantName
});
return next(action);
}
break;
}
}
return next(action);
});

View File

@@ -1,17 +1,13 @@
import { generateCollaborationLinkData } from '@jitsi/excalidraw';
import { AnyAction } from 'redux';
import { createOpenWhiteboardEvent } from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { hideDialog, openDialog } from '../base/dialog/actions';
import { isDialogOpen } from '../base/dialog/functions';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { participantJoined, participantLeft, pinParticipant } from '../base/participants/actions';
import { FakeParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { getCurrentRoomId } from '../breakout-rooms/functions';
import { addStageParticipant } from '../filmstrip/actions.web';
import { isStageFilmstripAvailable } from '../filmstrip/functions.web';
@@ -19,9 +15,7 @@ import { isStageFilmstripAvailable } from '../filmstrip/functions.web';
import { RESET_WHITEBOARD, SET_WHITEBOARD_OPEN } from './actionTypes';
import {
notifyWhiteboardLimit,
resetWhiteboard,
restrictWhiteboard,
setWhiteboardOpen,
setupWhiteboard
} from './actions';
import WhiteboardLimitDialog from './components/web/WhiteboardLimitDialog';
@@ -29,13 +23,14 @@ import { WHITEBOARD_ID, WHITEBOARD_PARTICIPANT_NAME } from './constants';
import {
getCollabDetails,
getCollabServerUrl,
isWhiteboardOpen,
isWhiteboardPresent,
shouldEnforceUserLimit,
shouldNotifyUserLimit
} from './functions';
import { WhiteboardStatus } from './types';
import './middleware.any';
const focusWhiteboard = (store: IStore) => {
const { dispatch, getState } = store;
const state = getState();
@@ -79,7 +74,7 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => async (action
dispatch(restrictWhiteboard(false));
dispatch(openDialog(WhiteboardLimitDialog));
return;
return next(action);
}
if (!existingCollabDetails) {
@@ -99,14 +94,14 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => async (action
});
raiseWhiteboardNotification(WhiteboardStatus.INSTANTIATED);
return;
return next(action);
}
if (action.isOpen) {
if (enforceUserLimit) {
dispatch(restrictWhiteboard());
return;
return next(action);
}
if (notifyUserLimit) {
@@ -118,10 +113,9 @@ MiddlewareRegistry.register((store: IStore) => (next: Function) => async (action
}
focusWhiteboard(store);
sendAnalytics(createOpenWhiteboardEvent());
raiseWhiteboardNotification(WhiteboardStatus.SHOWN);
return;
return next(action);
}
dispatch(participantLeft(WHITEBOARD_ID, conference, { fakeParticipant: FakeParticipant.Whiteboard }));
@@ -152,49 +146,3 @@ function raiseWhiteboardNotification(status: WhiteboardStatus) {
}
}
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed, e.g. Disable the whiteboard if it's left open.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference): void => {
if (conference !== previousConference) {
dispatch(resetWhiteboard());
}
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.METADATA_UPDATED, (metadata: any) => {
if (metadata[WHITEBOARD_ID]) {
dispatch(setupWhiteboard({
collabDetails: metadata[WHITEBOARD_ID].collabDetails
}));
dispatch(setWhiteboardOpen(true));
}
});
}
});
/**
* Set up state change listener to limit whiteboard access.
*/
StateListenerRegistry.register(
state => shouldEnforceUserLimit(state),
(enforceUserLimit, { dispatch, getState }): void => {
if (isWhiteboardOpen(getState()) && enforceUserLimit) {
dispatch(restrictWhiteboard());
}
}
);
/**
* Set up state change listener to notify about whiteboard usage.
*/
StateListenerRegistry.register(
state => shouldNotifyUserLimit(state),
(notifyUserLimit, { dispatch, getState }, prevNotifyUserLimit): void => {
if (isWhiteboardOpen(getState()) && notifyUserLimit && !prevNotifyUserLimit) {
dispatch(notifyWhiteboardLimit());
}
}
);

View File

@@ -7,6 +7,7 @@ import Platform from './features/base/react/Platform.web';
import { getJitsiMeetGlobalNS } from './features/base/util/helpers';
import DialInSummaryApp from './features/invite/components/dial-in-summary/web/DialInSummaryApp';
import PrejoinApp from './features/prejoin/components/web/PrejoinApp';
import WhiteboardApp from './features/whiteboard/components/web/WhiteboardApp';
const logger = getLogger('index.web');
@@ -60,7 +61,8 @@ document.addEventListener('DOMContentLoaded', () => {
globalNS.entryPoints = {
APP: App,
PREJOIN: PrejoinApp,
DIALIN: DialInSummaryApp
DIALIN: DialInSummaryApp,
WHITEBOARD: WhiteboardApp
};
globalNS.renderEntryPoint = ({

View File

@@ -61,6 +61,11 @@ function broadcastMetadata(room)
for _, occupant in room:each_occupant() do
if jid_node(occupant.jid) ~= 'focus' then
send_json_msg(occupant.jid, json_msg)
else
-- Jicofo reads room_metadata only from the MUC config form. Notify it that the form changed.
module:send(st.message({ type='groupchat', from=room.jid, to = occupant.jid })
:tag('x', { xmlns='http://jabber.org/protocol/muc#user' })
:tag('status', { code='104' }));
end
end
end

33
static/whiteboard.html Normal file
View File

@@ -0,0 +1,33 @@
<html xmlns="http://www.w3.org/1999/html">
<head>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html;charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!--#include virtual="/base.html" -->
<link rel="stylesheet" href="css/all.css">
<script>
window.EXCALIDRAW_ASSET_PATH = 'libs/';
document.addEventListener('DOMContentLoaded', async () => {
if (!JitsiMeetJS.app) {
return;
}
JitsiMeetJS.app.renderEntryPoint({
Component: JitsiMeetJS.app.entryPoints.WHITEBOARD
});
});
</script>
<!--#include virtual="/title.html" -->
<script><!--#include virtual="/config.js" --></script>
<script><!--#include virtual="/interface_config.js" --></script>
<script src="libs/lib-jitsi-meet.min.js?v=139"></script>
<script src="libs/app.bundle.min.js?v=139"></script>
</head>
<body>
<div id="react" role="main"></div>
</body>
</html>