Compare commits

...

30 Commits

Author SHA1 Message Date
damencho
9a5039e006 feat(tests): Clear previous videoConferenceLeft events. 2025-08-22 09:26:32 -05:00
damencho
78dcc1fd4c feat(tests): Increase wait time for webhooks. 2025-08-22 08:38:26 -05:00
damencho
aabfe88ad1 chore(package.json): use LJM from release-8717 branch. 2025-08-18 16:23:16 -05:00
damencho
535455fd5c fix: More fixes sending metadata to jicofo.
f1a0012 was not enough to address the issue.
2025-08-18 11:47:35 -05:00
damencho
fe72123415 fix: Fixes sending metadata to jicofo.
In cases like waiting-for-host lobby, jicofo can leave the room and rejoin later, without the room being destroyed. We need to make sure the metadata will reach jicofo on second attempt.
2025-08-11 17:02:00 +03:00
Hristo Terezov
54be267b59 fix(ChatMessage): context menu position
Before the chat message context menu was appearing on the left if the private chat message was disabled. The fix makes the context menu appear on the left only for messages from the local partcipant which are the only messages rendered to the right (therefore the context menu have to appear on the left side). For all other messages the context menu should appear on the right side because the message is positioned on the left side.
2025-08-05 10:59:21 -05:00
Hristo Terezov
27e0f31207 feat(visitors): Private messages to main participants. 2025-08-05 10:58:54 -05:00
Hristo Terezov
206eb1d2d4 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2030.0.0+b225c920...v2033.0.0+bf3e3a8e
2025-08-05 10:58:45 -05:00
Horatiu Muresan
5f57185c3f fix(i18n) Fix showing Afrikaans when set language is not found (#16245)
- fix translates sort
2025-08-04 09:53:46 -05:00
Hristo Terezov
4dede8d81e fix(participants-pane): restore scrolling and fix context menu clipping
The participant pane lost its scrolling capability when commit 2305ae85a removed the overflowY: 'auto' property from the container styles. This prevented users from scrolling through long lists of participants, breakout rooms, or visitors when the content exceeded the available height.

Additionally, context menus were being clipped on the left side due to the overflow constraints. This became apparent after the av-moderation feature added longer menu items like "Stop screen-sharing for everyone else".

Fix:
- Restore overflowY: 'auto' to enable vertical scrolling
- Add maxWidth constraint (285px) to context menus to prevent horizontal clipping
- Allow menu text to wrap to multiple lines instead of being cut off
- Add TODO comment for future portal-based implementation

This temporary solution provides both functional scrolling and fully readable context menus until a proper architectural change can be implemented to portal context menus outside the scrollable container.
2025-08-01 10:16:00 -05:00
Hristo Terezov
a4531495ad fix(deeplinking): Prevent web specific files beeing included in native build.
Adds .web suffixes to all web specific files to prevent beeing included in the native build. Before this it seems those files were included in the build but by some chance nothing was failing.
2025-08-01 10:15:40 -05:00
Hristo Terezov
5f5fa5a2f5 fix: GUM prompt not displayed after deeplinking page.
When we open a custom scheme URL before the window load event has been fired it seems that GUM prompt is not displayed after this due to Chrome bug. See more details here https://issues.chromium.org/issues/41398687.

The result in Jitsi Meet is the following:
If the user is joining a call for first time and haven't granted A/V permissions and lands on the deeplinking page we try to open the desktop app via redirect to a custom scheme URL. If the user chooses cancel and "Launch in web" we go to the prejoin screen and proceed with the initial GUM. At this point any GUM call won't display the permission prompt due to the browser bug and will go on forever making it impossible for the user to unmute camera or microphone.
2025-08-01 10:15:31 -05:00
Horatiu Muresan
0db9f1b7b4 chore(analytics) Add getter for amplitude deviceId (#16268) 2025-07-25 20:44:26 +03:00
damencho
c1257e70e3 fix: Fixes table equals.
Was checking only for added or removed keys, but not for modified values.
2025-07-24 14:47:03 +03:00
damencho
858361b344 fix: Drops salesforce notification. 2025-07-23 18:27:09 +03:00
damencho
9e79494487 fix(av-moderation): Updates the whitelist with every moderator.
When a moderator joins or someone is granted moderation we update the whitelist for any media type for which moderation is enabled. The updated whitelist is sent to all the moderators including the newly joined or granted one.
2025-07-23 10:55:39 +03:00
damencho
d1764ea4a0 fix: Showing go-live notification.
Handle the case when a local participant becomes moderator after metadata is updated.
2025-07-22 16:56:06 +03:00
Hristo Terezov
e2ce8f803b fix(visitors): Add fallback display names for empty visitor names
Visitors with empty or undefined names now show the configured
defaultRemoteDisplayName or 'Fellow Jitster' as fallback, matching
the behavior of regular remote participants.
2025-07-22 16:55:59 +03:00
ltorje-8x8
127cfeb28d fix(go-live): unsubscribe from topics before closing if not done already (#16244) 2025-07-22 16:55:51 +03:00
damencho
6fab1a3346 fix: Fixes updating local UI startMuted state. 2025-07-22 16:55:21 +03:00
damencho
53c1f3791c fix: Fixes restoring startmuted in av mod. 2025-07-22 16:55:13 +03:00
damencho
f9084661e4 fix: Avoids sending two metadata updates.
When setting startMuted we are sending two metadata updates.
2025-07-22 16:55:03 +03:00
damencho
513dfb0fca fix: Drops not needed default values when filtering. 2025-07-22 16:54:54 +03:00
damencho
3109d0a3f9 fix: Filters stanza on cloned copy. 2025-07-22 16:54:34 +03:00
Hristo Terezov
111ff5d10b fix(go-live): waiting not updated correctly.
We were comparing if the number of waiting participants have changed with the wrong property from the state - the number of visitors. The result was that we won't update the state when the new waiting value matches the number of visitors already in the state. Most of the times this will be 0 and we would never go to 0.
2025-07-16 08:47:22 -05:00
Hristo Terezov
40ce690b4b fix(go-live): Disconnect on page close.
Currently we don't close the socket for the participants in the queue when the page is closed.
2025-07-15 18:56:49 -05:00
bgrozev
91eeadf25e fix: Fix hideDisplayNameForAll. (#16239)
Remove filtering on the receive side, because:
1. It's not applied to visitors, and should be for the "all" case
2. We don't want to strip stats-id from stanzas sent to jicofo
2025-07-15 11:28:48 -05:00
bgrozev
1ccd5dc57c fix: Fix setting whitelist when av_moderation is initially enabled. (#16235) 2025-07-14 18:37:49 -05:00
Mihaela Dumitru
90c4e052d9 fix(lang) add missing desktop sharing keys (#16234) 2025-07-14 18:09:31 -05:00
ltorje-8x8
c6851807a1 JIT-14750 Do not show names to visitors (#16231)
* JIT-14750 Do not show names to visitors

* apply review

* change name and email too

* fix: Fix filtering initial presence to vnodes.

* Also strip stats-id and identity.user.name.

* Move filtering logic to a util, filter all identity in main room

---------

Co-authored-by: Boris Grozev <boris@jitsi.org>
2025-07-14 16:05:00 -05:00
52 changed files with 883 additions and 300 deletions

View File

@@ -50,7 +50,8 @@ import {
commonUserJoinedHandling,
commonUserLeftHandling,
getConferenceOptions,
sendLocalParticipant
sendLocalParticipant,
updateTrackMuteState
} from './react/features/base/conference/functions';
import { getReplaceParticipant, getSsrcRewritingFeatureFlag } from './react/features/base/config/functions';
import { connect } from './react/features/base/connection/actions.web';
@@ -1663,8 +1664,12 @@ export default {
room.on(
JitsiConferenceEvents.START_MUTED_POLICY_CHANGED,
({ audio, video }) => {
APP.store.dispatch(
onStartMutedPolicyChanged(audio, video));
APP.store.dispatch(onStartMutedPolicyChanged(audio, video));
const state = APP.store.getState();
updateTrackMuteState(state, APP.store.dispatch, true);
updateTrackMuteState(state, APP.store.dispatch, false);
}
);

View File

@@ -375,6 +375,7 @@
"micTimeoutError": "Could not start audio source. Timeout occurred!",
"micUnknownError": "Cannot use microphone for an unknown reason.",
"moderationAudioLabel": "Allow attendees to unmute themselves",
"moderationDesktopLabel": "Allow non-moderators to share their screen",
"moderationVideoLabel": "Allow non-moderators to start their video",
"muteEveryoneDialog": "The participants can unmute themselves at any time.",
"muteEveryoneDialogModerationOn": "The participants can send a request to speak at any time.",
@@ -387,6 +388,9 @@
"muteEveryoneSelf": "yourself",
"muteEveryoneStartMuted": "Everyone starts muted from now on",
"muteEveryoneTitle": "Mute everyone?",
"muteEveryonesDesktopDialog": "The participants can share their screen at any time.",
"muteEveryonesDesktopDialogModerationOn": "The participants can send a request to share their screen at any time.",
"muteEveryonesDesktopTitle": "Stop everyone's screen share?",
"muteEveryonesVideoDialog": "The participants can turn on their video at any time.",
"muteEveryonesVideoDialogModerationOn": "The participants can send a request to turn on their video at any time.",
"muteEveryonesVideoDialogOk": "Disable",

9
package-lock.json generated
View File

@@ -64,7 +64,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/v2030.0.0+b225c920/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-8717",
"lodash-es": "4.17.21",
"null-loader": "4.0.1",
"optional-require": "1.0.3",
@@ -18090,8 +18090,7 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2030.0.0+b225c920/lib-jitsi-meet.tgz",
"integrity": "sha512-UjQElh+1XA7sk9Q8fADLoAuyE3/CDE0Oc0jbqFjeSzhu9LLbbARZEyP9FoeYQ1miTNf7hI3/QVGRkpBNCdqGKA==",
"resolved": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#ff978008229b898c4aa61e68199eeb9f3cd7cefb",
"license": "Apache-2.0",
"dependencies": {
"@jitsi/js-utils": "2.4.6",
@@ -39348,8 +39347,8 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2030.0.0+b225c920/lib-jitsi-meet.tgz",
"integrity": "sha512-UjQElh+1XA7sk9Q8fADLoAuyE3/CDE0Oc0jbqFjeSzhu9LLbbARZEyP9FoeYQ1miTNf7hI3/QVGRkpBNCdqGKA==",
"version": "git+ssh://git@github.com/jitsi/lib-jitsi-meet.git#ff978008229b898c4aa61e68199eeb9f3cd7cefb",
"from": "lib-jitsi-meet@https://github.com/jitsi/lib-jitsi-meet#release-8717",
"requires": {
"@jitsi/js-utils": "2.4.6",
"@jitsi/logger": "2.0.2",

View File

@@ -70,7 +70,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/v2030.0.0+b225c920/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet#release-8717",
"lodash-es": "4.17.21",
"null-loader": "4.0.1",
"optional-require": "1.0.3",

View File

@@ -35,3 +35,12 @@ export function fixDeviceID(amplitude: Types.BrowserClient) {
}
}
}
/**
* Returns the amplitude shared deviceId.
*
* @returns {string} - The amplitude deviceId.
*/
export function getDeviceID() {
return jitsiLocalStorage.getItem(DEVICE_ID_KEY);
}

View File

@@ -1,8 +1,10 @@
import { IStateful } from '../base/app/types';
import { toState } from '../base/redux/functions';
import { getServerURL } from '../base/settings/functions.web';
import { getJitsiMeetGlobalNS } from '../base/util/helpers';
export * from './functions.any';
import logger from './logger';
/**
* Retrieves the default URL for the app. This can either come from a prop to
@@ -31,3 +33,27 @@ export function getDefaultURL(stateful: IStateful) {
export function getName() {
return interfaceConfig.APP_NAME;
}
/**
* Executes a handler function after the window load event has been received.
* If the app has already loaded, the handler is executed immediately.
* Otherwise, the handler is registered as a 'load' event listener.
*
* @param {Function} handler - The callback function to execute.
* @returns {void}
*/
export function executeAfterLoad(handler: () => void) {
const safeHandler = () => {
try {
handler();
} catch (error) {
logger.error('Error executing handler after load:', error);
}
};
if (getJitsiMeetGlobalNS()?.hasLoaded) {
safeHandler();
} else {
window.addEventListener('load', safeHandler);
}
}

View File

@@ -24,7 +24,6 @@ import '../calendar-sync/middleware';
import '../chat/middleware';
import '../conference/middleware';
import '../connection-indicator/middleware';
import '../deep-linking/middleware';
import '../device-selection/middleware';
import '../display-name/middleware';
import '../dynamic-branding/middleware';

View File

@@ -2,6 +2,7 @@ import '../base/app/middleware';
import '../base/connection/middleware';
import '../base/devices/middleware';
import '../base/media/middleware';
import '../deep-linking/middleware.web';
import '../dynamic-branding/middleware';
import '../e2ee/middleware';
import '../external-api/middleware';

View File

@@ -83,7 +83,8 @@ import {
getConferenceState,
getCurrentConference,
getVisitorOptions,
sendLocalParticipant
sendLocalParticipant,
updateTrackMuteState
} from './functions';
import logger from './logger';
import { IConferenceMetadata, IJitsiConference } from './reducer';
@@ -186,6 +187,15 @@ function _addConferenceListeners(conference: IJitsiConference, dispatch: IStore[
(disableVideoMuteChange: boolean) => {
dispatch(setVideoUnmutePermissions(disableVideoMuteChange));
});
conference.on(
JitsiConferenceEvents.START_MUTED_POLICY_CHANGED,
({ audio, video }: { audio: boolean; video: boolean; }) => {
dispatch(onStartMutedPolicyChanged(audio, video));
updateTrackMuteState(state, dispatch, true);
updateTrackMuteState(state, dispatch, false);
}
);
// Dispatches into features/base/tracks follow:
@@ -1013,6 +1023,8 @@ export function setStartMutedPolicy(
audio: startAudioMuted,
video: startVideoMuted
});
dispatch(onStartMutedPolicyChanged(startAudioMuted, startVideoMuted));
};
}

View File

@@ -40,3 +40,8 @@ export const CONFERENCE_LEAVE_REASONS = {
SWITCH_ROOM: 'switch_room',
UNRECOVERABLE_ERROR: 'unrecoverable_error'
};
/**
* The ID of the notification that is shown when the user is muted by focus.
*/
export const START_MUTED_NOTIFICATION_ID = 'start-muted';

View File

@@ -3,9 +3,13 @@ import { upperFirst, words } from 'lodash-es';
import { getName } from '../../app/functions';
import { IReduxState, IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { determineTranscriptionLanguage } from '../../transcribing/functions';
import { IStateful } from '../app/types';
import { JitsiTrackErrors } from '../lib-jitsi-meet';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import {
participantJoined,
participantLeft
@@ -22,7 +26,8 @@ import { setObfuscatedRoom } from './actions';
import {
AVATAR_URL_COMMAND,
EMAIL_COMMAND,
JITSI_CONFERENCE_URL_KEY
JITSI_CONFERENCE_URL_KEY,
START_MUTED_NOTIFICATION_ID
} from './constants';
import logger from './logger';
import { IJitsiConference } from './reducer';
@@ -574,3 +579,42 @@ function safeStartCase(s = '') {
(result, word, index) => result + (index ? ' ' : '') + upperFirst(word)
, '');
}
/**
* Updates the mute state of the track based on the start muted policy.
*
* @param {Object|Function} stateful - Either the whole Redux state object or the Redux store's {@code getState} method.
* @param {Function} dispatch - Redux dispatch function.
* @param {boolean} isAudio - Whether the track is audio or video.
* @returns {void}
*/
export function updateTrackMuteState(stateful: IStateful, dispatch: IStore['dispatch'], isAudio: boolean) {
const state = toState(stateful);
const mutedPolicyKey = isAudio ? 'startAudioMutedPolicy' : 'startVideoMutedPolicy';
const mutedPolicyValue = state['features/base/conference'][mutedPolicyKey];
// Currently, the policy only supports force muting others, not unmuting them.
if (!mutedPolicyValue) {
return;
}
let muteStateUpdated = false;
const { muted } = isAudio ? state['features/base/media'].audio : state['features/base/media'].video;
if (isAudio && !Boolean(muted)) {
dispatch(setAudioMuted(mutedPolicyValue, true));
muteStateUpdated = true;
} else if (!isAudio && !Boolean(muted)) {
// TODO: Add a new authority for video mutism for the moderator case.
dispatch(setVideoMuted(mutedPolicyValue, VIDEO_MUTISM_AUTHORITY.USER, true));
muteStateUpdated = true;
}
if (muteStateUpdated) {
dispatch(showNotification({
titleKey: 'notify.mutedTitle',
descriptionKey: 'notify.muted',
uid: START_MUTED_NOTIFICATION_ID // use the same id, to make sure we show one notification
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
}

View File

@@ -72,7 +72,6 @@ import {
} from './functions';
import logger from './logger';
import { IConferenceMetadata } from './reducer';
import './subscriber';
/**
* Handler for before unload event.

View File

@@ -1,61 +0,0 @@
import { IStore } from '../../app/types';
import { showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { setAudioMuted, setVideoMuted } from '../media/actions';
import { VIDEO_MUTISM_AUTHORITY } from '../media/constants';
import StateListenerRegistry from '../redux/StateListenerRegistry';
let hasShownNotification = false;
/**
* Handles changes in the start muted policy for audio and video tracks in the meta data set for the conference.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/conference'].startAudioMutedPolicy,
/* listener */ (startAudioMutedPolicy, store) => {
_updateTrackMuteState(store, true);
});
StateListenerRegistry.register(
/* selector */ state => state['features/base/conference'].startVideoMutedPolicy,
/* listener */(startVideoMutedPolicy, store) => {
_updateTrackMuteState(store, false);
});
/**
* Updates the mute state of the track based on the start muted policy.
*
* @param {IStore} store - The redux store.
* @param {boolean} isAudio - Whether the track is audio or video.
* @returns {void}
*/
function _updateTrackMuteState(store: IStore, isAudio: boolean) {
const { dispatch, getState } = store;
const mutedPolicyKey = isAudio ? 'startAudioMutedPolicy' : 'startVideoMutedPolicy';
const mutedPolicyValue = getState()['features/base/conference'][mutedPolicyKey];
// Currently, the policy only supports force muting others, not unmuting them.
if (!mutedPolicyValue) {
return;
}
let muteStateUpdated = false;
const { muted } = isAudio ? getState()['features/base/media'].audio : getState()['features/base/media'].video;
if (isAudio && !Boolean(muted)) {
dispatch(setAudioMuted(mutedPolicyValue, true));
muteStateUpdated = true;
} else if (!isAudio && !Boolean(muted)) {
// TODO: Add a new authority for video mutism for the moderator case.
dispatch(setVideoMuted(mutedPolicyValue, VIDEO_MUTISM_AUTHORITY.USER, true));
muteStateUpdated = true;
}
if (!hasShownNotification && muteStateUpdated) {
hasShownNotification = true;
dispatch(showNotification({
titleKey: 'notify.mutedTitle',
descriptionKey: 'notify.muted'
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
}
}

View File

@@ -553,7 +553,7 @@ export interface IConfig {
disableDemote?: boolean;
disableGrantModerator?: boolean;
disableKick?: boolean;
disablePrivateChat?: 'all' | 'allow-moderator-chat';
disablePrivateChat?: 'all' | 'allow-moderator-chat' | 'disable-visitor-chat';
disabled?: boolean;
};
replaceParticipant?: string;

View File

@@ -2,10 +2,12 @@
import { getGravatarURL } from '@jitsi/js-utils/avatar';
import { IReduxState, IStore } from '../../app/types';
import { isVisitorChatParticipant } from '../../chat/functions';
import { isStageFilmstripAvailable } from '../../filmstrip/functions';
import { isAddPeopleEnabled, isDialOutEnabled } from '../../invite/functions';
import { toggleShareDialog } from '../../share-room/actions';
import { iAmVisitor } from '../../visitors/functions';
import { IVisitorChatParticipant } from '../../visitors/types';
import { IStateful } from '../app/types';
import { GRAVATAR_BASE_URL } from '../avatar/constants';
import { isCORSAvatarURL } from '../avatar/functions';
@@ -827,18 +829,28 @@ export const setShareDialogVisiblity = (addPeopleFeatureEnabled: boolean, dispat
/**
* Checks if private chat is enabled for the given participant.
*
* @param {IParticipant|undefined} participant - The participant to check.
* @param {IParticipant|IVisitorChatParticipant|undefined} participant - The participant to check.
* @param {IReduxState} state - The Redux state.
* @returns {boolean} - True if private chat is enabled, false otherwise.
*/
export function isPrivateChatEnabled(participant: IParticipant | undefined, state: IReduxState) {
export function isPrivateChatEnabled(participant: IParticipant | IVisitorChatParticipant | undefined, state: IReduxState) {
const { remoteVideoMenu = {} } = state['features/base/config'];
const { disablePrivateChat } = remoteVideoMenu;
if (participant?.local || state['features/visitors'].iAmVisitor || disablePrivateChat === 'all') {
if ((!isVisitorChatParticipant(participant) && participant?.local) || disablePrivateChat === 'all') {
return false;
}
if (disablePrivateChat === 'disable-visitor-chat') {
// Block if the participant we're trying to message is a visitor
// OR if the local user is a visitor
if (isVisitorChatParticipant(participant) || iAmVisitor(state)) {
return false;
}
return true; // should allow private chat for other participants
}
if (disablePrivateChat === 'allow-moderator-chat') {
return isLocalParticipantModerator(state) || isParticipantModerator(participant);
}

View File

@@ -4,6 +4,7 @@ import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { getParticipantById } from '../../base/participants/functions';
import { IParticipant } from '../../base/participants/types';
import { IVisitorChatParticipant } from '../../visitors/types';
import { sendMessage, setPrivateMessageRecipient } from '../actions';
interface IProps extends WithTranslation {
@@ -23,6 +24,16 @@ interface IProps extends WithTranslation {
*/
_participant?: IParticipant;
/**
* The display name of the visitor (if applicable).
*/
displayName?: string;
/**
* Whether the message is from a visitor.
*/
isFromVisitor?: boolean;
/**
* The message that is about to be sent.
*/
@@ -67,9 +78,21 @@ export class AbstractChatPrivacyDialog extends PureComponent<IProps> {
* @returns {void}
*/
_onSendPrivateMessage() {
const { message, _onSendMessage, _onSetMessageRecipient, _participant } = this.props;
const { message, _onSendMessage, _onSetMessageRecipient, _participant, isFromVisitor, displayName, participantID } = this.props;
if (isFromVisitor) {
// For visitors, create a participant object since they don't exist in the main participant list
const visitorParticipant = {
id: participantID,
name: displayName,
isVisitor: true
};
_onSetMessageRecipient(visitorParticipant);
} else {
_onSetMessageRecipient(_participant);
}
_onSetMessageRecipient(_participant);
_onSendMessage(message);
return true;
@@ -88,7 +111,7 @@ export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
dispatch(sendMessage(message, true));
},
_onSetMessageRecipient: (participant: IParticipant) => {
_onSetMessageRecipient: (participant: IParticipant | IVisitorChatParticipant) => {
dispatch(setPrivateMessageRecipient(participant));
}
};
@@ -103,6 +126,6 @@ export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
return {
_participant: getParticipantById(state, ownProps.participantID)
_participant: ownProps.isFromVisitor ? undefined : getParticipantById(state, ownProps.participantID)
};
}

View File

@@ -3,7 +3,9 @@ import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { getParticipantDisplayName, isLocalParticipantModerator } from '../../base/participants/functions';
import { getVisitorDisplayName } from '../../visitors/functions';
import { setLobbyChatActiveState, setPrivateMessageRecipient } from '../actions.any';
import { isVisitorChatParticipant } from '../functions';
export interface IProps extends WithTranslation {
@@ -13,6 +15,11 @@ export interface IProps extends WithTranslation {
*/
_isLobbyChatActive: boolean;
/**
* Whether the private message recipient is a visitor.
*/
_isVisitor?: boolean;
/**
* The name of the lobby message recipient, if any.
*/
@@ -72,10 +79,18 @@ export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
*/
export function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { privateMessageRecipient, lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
let _privateMessageRecipient;
const _isVisitor = isVisitorChatParticipant(privateMessageRecipient);
if (privateMessageRecipient) {
_privateMessageRecipient = _isVisitor
? getVisitorDisplayName(state, privateMessageRecipient.name)
: getParticipantDisplayName(state, privateMessageRecipient.id);
}
return {
_privateMessageRecipient:
privateMessageRecipient ? getParticipantDisplayName(state, privateMessageRecipient.id) : undefined,
_privateMessageRecipient,
_isVisitor,
_isLobbyChatActive: isLobbyChatActive,
_lobbyMessageRecipient:
isLobbyChatActive && lobbyMessageRecipient ? lobbyMessageRecipient.name : undefined,

View File

@@ -122,15 +122,17 @@ class ChatMessage extends Component<IChatMessageProps> {
* @returns {React.ReactElement<*> | null}
*/
_renderDisplayName() {
const { message, showDisplayName } = this.props;
const { message, showDisplayName, t } = this.props;
if (!showDisplayName) {
return null;
}
const { displayName, isFromVisitor } = message;
return (
<Text style = { styles.senderDisplayName }>
{ message.displayName }
{ `${displayName}${isFromVisitor ? ` ${t('visitors.chatIndicator')}` : ''}` }
</Text>
);
}

View File

@@ -12,7 +12,8 @@ import {
} from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setLobbyChatActiveState, setPrivateMessageRecipient } from '../../actions.any';
import AbstractMessageRecipient, {
IProps as AbstractProps
IProps as AbstractProps,
_mapStateToProps as _mapStateToPropsAbstract
} from '../AbstractMessageRecipient';
import styles from './styles';
@@ -36,11 +37,6 @@ interface IProps extends AbstractProps {
id: string;
name: string;
} | ILocalParticipant;
/**
* The participant object set for private messaging.
*/
privateMessageRecipient: { name: string; };
}
/**
@@ -96,7 +92,8 @@ class MessageRecipient extends AbstractMessageRecipient<IProps> {
const {
isLobbyChatActive,
lobbyMessageRecipient,
privateMessageRecipient,
_privateMessageRecipient,
_isVisitor,
t
} = this.props;
@@ -120,7 +117,7 @@ class MessageRecipient extends AbstractMessageRecipient<IProps> {
);
}
if (!privateMessageRecipient) {
if (!_privateMessageRecipient) {
return null;
}
@@ -130,7 +127,7 @@ class MessageRecipient extends AbstractMessageRecipient<IProps> {
style = { styles.messageRecipientContainer as ViewStyle }>
<Text style = { styles.messageRecipientText }>
{ t('chat.messageTo', {
recipient: privateMessageRecipient.name
recipient: `${_privateMessageRecipient}${_isVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`
}) }
</Text>
<TouchableHighlight
@@ -157,6 +154,7 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
return {
..._mapStateToPropsAbstract(state, _ownProps),
isLobbyChatActive,
lobbyMessageRecipient
};

View File

@@ -9,6 +9,7 @@ import { getParticipantById, getParticipantDisplayName, isPrivateChatEnabled } f
import Popover from '../../../base/popover/components/Popover.web';
import Message from '../../../base/react/components/web/Message';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { MESSAGE_TYPE_LOCAL } from '../../constants';
import { getFormattedTimestamp, getMessageText, getPrivateNoticeMessage } from '../../functions';
import { IChatMessageProps } from '../../types';
@@ -16,9 +17,10 @@ import MessageMenu from './MessageMenu';
import ReactButton from './ReactButton';
interface IProps extends IChatMessageProps {
shouldDisplayChatMessageMenu: boolean;
className?: string;
enablePrivateChat?: boolean;
shouldDisplayMenuOnRight?: boolean;
state?: IReduxState;
type: string;
}
const useStyles = makeStyles()((theme: Theme) => {
@@ -190,11 +192,12 @@ const useStyles = makeStyles()((theme: Theme) => {
});
const ChatMessage = ({
className = '',
message,
state,
showDisplayName,
type,
shouldDisplayChatMessageMenu,
shouldDisplayMenuOnRight,
enablePrivateChat,
knocking,
t
}: IProps) => {
@@ -224,11 +227,13 @@ const ChatMessage = ({
* @returns {React$Element<*>}
*/
function _renderDisplayName() {
const { displayName, isFromVisitor = false } = message;
return (
<div
aria-hidden = { true }
className = { cx('display-name', classes.displayName) }>
{message.displayName}
{`${displayName}${isFromVisitor ? ` ${t('visitors.chatIndicator')}` : ''}`}
</div>
);
}
@@ -329,26 +334,28 @@ const ChatMessage = ({
return (
<div
className = { cx(classes.chatMessageWrapper, type) }
className = { cx(classes.chatMessageWrapper, className) }
id = { message.messageId }
onMouseEnter = { handleMouseEnter }
onMouseLeave = { handleMouseLeave }
tabIndex = { -1 }>
<div className = { classes.sideBySideContainer }>
{!shouldDisplayChatMessageMenu && (
{!shouldDisplayMenuOnRight && (
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
message = { message.message }
participantId = { message.participantId }
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
participantId = { message.participantId } />}
</div>
)}
<div
className = { cx(
'chatmessage',
classes.chatMessage,
type,
className,
message.privateMessage && 'privatemessage',
message.lobbyChat && !knocking && 'lobbymessage'
) }>
@@ -379,7 +386,7 @@ const ChatMessage = ({
</div>
</div>
</div>
{shouldDisplayChatMessageMenu && (
{shouldDisplayMenuOnRight && (
<div className = { classes.sideBySideContainer }>
{!message.privateMessage && !message.lobbyChat && <div>
<div className = { classes.optionsButtonContainer }>
@@ -391,10 +398,12 @@ const ChatMessage = ({
<div>
<div className = { classes.optionsButtonContainer }>
{isHovered && <MessageMenu
displayName = { message.displayName }
enablePrivateChat = { Boolean(enablePrivateChat) }
isFromVisitor = { message.isFromVisitor }
isLobbyMessage = { message.lobbyChat }
message = { message.message }
participantId = { message.participantId }
shouldDisplayChatMessageMenu = { shouldDisplayChatMessageMenu } />}
participantId = { message.participantId } />}
</div>
</div>
</div>
@@ -414,10 +423,23 @@ function _mapStateToProps(state: IReduxState, { message }: IProps) {
const { knocking } = state['features/lobby'];
const participant = getParticipantById(state, message.participantId);
const enablePrivateChat = isPrivateChatEnabled(participant, state);
// For visitor private messages, participant will be undefined but we should still allow private chat
// Create a visitor participant object for visitor messages to pass to isPrivateChatEnabled
const participantForCheck = message.isFromVisitor
? { id: message.participantId, name: message.displayName, isVisitor: true as const }
: participant;
const enablePrivateChat = (!message.isFromVisitor || message.privateMessage)
&& isPrivateChatEnabled(participantForCheck, state);
// Only the local messages appear on the right side of the chat therefore only for them the menu has to be on the
// left side.
const shouldDisplayMenuOnRight = message.messageType !== MESSAGE_TYPE_LOCAL;
return {
shouldDisplayChatMessageMenu: enablePrivateChat,
shouldDisplayMenuOnRight,
enablePrivateChat,
knocking,
state
};

View File

@@ -71,12 +71,11 @@ const ChatMessageGroup = ({ className = '', messages }: IProps) => {
<div className = { `${classes.messageGroup} chat-message-group ${className}` }>
{messages.map((message, i) => (
<ChatMessage
className = { className }
key = { i }
message = { message }
shouldDisplayChatMessageMenu = { false }
showDisplayName = { i === 0 }
showTimestamp = { i === messages.length - 1 }
type = { className } />
showTimestamp = { i === messages.length - 1 } />
))}
</div>
</div>

View File

@@ -15,10 +15,12 @@ import { handleLobbyChatInitialized, openChat } from '../../actions.web';
export interface IProps {
className?: string;
displayName?: string;
enablePrivateChat: boolean;
isFromVisitor?: boolean;
isLobbyMessage: boolean;
message: string;
participantId: string;
shouldDisplayChatMessageMenu: boolean;
}
const useStyles = makeStyles()(theme => {
@@ -58,7 +60,7 @@ const useStyles = makeStyles()(theme => {
};
});
const MessageMenu = ({ message, participantId, isLobbyMessage, shouldDisplayChatMessageMenu }: IProps) => {
const MessageMenu = ({ message, participantId, isFromVisitor, isLobbyMessage, enablePrivateChat, displayName }: IProps) => {
const dispatch = useDispatch();
const { classes, cx } = useStyles();
const { t } = useTranslation();
@@ -82,10 +84,23 @@ const MessageMenu = ({ message, participantId, isLobbyMessage, shouldDisplayChat
if (isLobbyMessage) {
dispatch(handleLobbyChatInitialized(participantId));
} else {
dispatch(openChat(participant));
// For visitor messages, participant will be undefined but we can still open chat
// using the participantId which contains the visitor's original JID
if (isFromVisitor) {
// Handle visitor participant that doesn't exist in main participant list
const visitorParticipant = {
id: participantId,
name: displayName,
isVisitor: true
};
dispatch(openChat(visitorParticipant));
} else {
dispatch(openChat(participant));
}
}
handleClose();
}, [ dispatch, isLobbyMessage, participant, participantId ]);
}, [ dispatch, isLobbyMessage, participant, participantId, displayName ]);
const handleCopyClick = useCallback(() => {
copyText(message)
@@ -115,7 +130,7 @@ const MessageMenu = ({ message, participantId, isLobbyMessage, shouldDisplayChat
const popoverContent = (
<div className = { classes.menuPanel }>
{shouldDisplayChatMessageMenu && (
{enablePrivateChat && (
<div
className = { classes.menuItem }
onClick = { handlePrivateClick }>

View File

@@ -48,6 +48,7 @@ const useStyles = makeStyles()(theme => {
const MessageRecipient = ({
_privateMessageRecipient,
_isLobbyChatActive,
_isVisitor,
_lobbyMessageRecipient,
_onRemovePrivateMessageRecipient,
_onHideLobbyChatRecipient,
@@ -80,9 +81,9 @@ const MessageRecipient = ({
id = 'chat-recipient'
role = 'alert'>
<span className = { classes.text }>
{t(_isLobbyChatActive ? 'chat.lobbyChatMessageTo' : 'chat.messageTo', {
recipient: _isLobbyChatActive ? _lobbyMessageRecipient : _privateMessageRecipient
})}
{ _isLobbyChatActive
? t('chat.lobbyChatMessageTo', { recipient: _lobbyMessageRecipient })
: t('chat.messageTo', { recipient: `${_privateMessageRecipient}${_isVisitor ? ` ${t('visitors.chatIndicator')}` : ''}` }) }
</span>
<Button
accessibilityLabel = { t('dialog.close') }

View File

@@ -9,10 +9,12 @@ import { getLocalizedDateFormatter } from '../base/i18n/dateUtil';
import i18next from '../base/i18n/i18next';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import { getParticipantById } from '../base/participants/functions';
import { getParticipantById, isPrivateChatEnabled } from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import { escapeRegexp } from '../base/util/helpers';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
import { IVisitorChatParticipant } from '../visitors/types';
import { MESSAGE_TYPE_ERROR, MESSAGE_TYPE_LOCAL, TIMESTAMP_FORMAT } from './constants';
import { IMessage } from './types';
@@ -178,9 +180,24 @@ export function getCanReplyToMessage(state: IReduxState, message: IMessage) {
const { knocking } = state['features/lobby'];
const participant = getParticipantById(state, message.participantId);
return Boolean(participant)
// Check if basic reply conditions are met
const basicCanReply = (Boolean(participant) || message.isFromVisitor)
&& (message.privateMessage || (message.lobbyChat && !knocking))
&& message.messageType !== MESSAGE_TYPE_LOCAL;
if (!basicCanReply) {
return false;
}
// Check private chat configuration for visitor messages
if (message.isFromVisitor) {
const visitorParticipant = { id: message.participantId, name: message.displayName, isVisitor: true as const };
return isPrivateChatEnabled(visitorParticipant, state);
}
// For non-visitor messages, use the regular participant
return isPrivateChatEnabled(participant, state);
}
/**
@@ -190,8 +207,19 @@ export function getCanReplyToMessage(state: IReduxState, message: IMessage) {
* @returns {string}
*/
export function getPrivateNoticeMessage(message: IMessage) {
let recipient;
if (message.messageType === MESSAGE_TYPE_LOCAL) {
// For messages sent by local user, show the recipient name
// For visitor messages, use the visitor's display name with indicator
recipient = message.sentToVisitor ? `${message.recipient} ${i18next.t('visitors.chatIndicator')}` : message.recipient;
} else {
// For messages received from others, show "you"
recipient = i18next.t('chat.you');
}
return i18next.t('chat.privateNotice', {
recipient: message.messageType === MESSAGE_TYPE_LOCAL ? message.recipient : i18next.t('chat.you')
recipient
});
}
@@ -225,3 +253,15 @@ export function getChatMaxSize(state: IReduxState) {
return Math.max(clientWidth - getParticipantsPaneWidth(state) - VIDEO_SPACE_MIN_SIZE, 0);
}
/**
* Type guard to check if a participant is a visitor chat participant.
*
* @param {IParticipant | IVisitorChatParticipant | undefined} participant - The participant to check.
* @returns {boolean} - True if the participant is a visitor chat participant.
*/
export function isVisitorChatParticipant(
participant?: IParticipant | IVisitorChatParticipant
): participant is IVisitorChatParticipant {
return Boolean(participant && 'isVisitor' in participant && participant.isVisitor === true);
}

View File

@@ -33,8 +33,7 @@ import { pushReactions } from '../reactions/actions.any';
import { ENDPOINT_REACTION_NAME } from '../reactions/constants';
import { getReactionMessageFromBuffer, isReactionsEnabled } from '../reactions/functions.any';
import { showToolbox } from '../toolbox/actions';
import './subscriber';
import { getVisitorDisplayName } from '../visitors/functions';
import {
ADD_MESSAGE,
@@ -55,8 +54,9 @@ import {
MESSAGE_TYPE_REMOTE,
MESSAGE_TYPE_SYSTEM
} from './constants';
import { getUnreadCount, isSendGroupChatDisabled } from './functions';
import { getUnreadCount, isSendGroupChatDisabled, isVisitorChatParticipant } from './functions';
import { INCOMING_MSG_SOUND_FILE } from './sounds';
import './subscriber';
/**
* Timeout for when to show the privacy notice after a private message was received.
@@ -186,6 +186,9 @@ MiddlewareRegistry.register(store => next => action => {
if (participant) {
action.participant = participant;
} else if (isVisitorChatParticipant(privateMessageRecipient)) {
// Handle visitor participants that don't exist in the main participant list
action.participant = privateMessageRecipient;
}
}
} else if (focusedTab === ChatTabs.POLLS) {
@@ -204,14 +207,17 @@ MiddlewareRegistry.register(store => next => action => {
// recipient. This logic tries to mitigate this risk.
const shouldSendPrivateMessageTo = _shouldSendPrivateMessageTo(state, action);
const participantExists = shouldSendPrivateMessageTo
&& getParticipantById(state, shouldSendPrivateMessageTo);
if (shouldSendPrivateMessageTo) {
const participantExists = getParticipantById(state, shouldSendPrivateMessageTo.id);
if (shouldSendPrivateMessageTo && participantExists) {
dispatch(openDialog(ChatPrivacyDialog, {
message: action.message,
participantID: shouldSendPrivateMessageTo
}));
if (participantExists || shouldSendPrivateMessageTo.isFromVisitor) {
dispatch(openDialog(ChatPrivacyDialog, {
message: action.message,
participantID: shouldSendPrivateMessageTo.id,
isFromVisitor: shouldSendPrivateMessageTo.isFromVisitor,
displayName: shouldSendPrivateMessageTo.name
}));
}
} else {
// Sending the message if privacy notice doesn't need to be shown.
@@ -227,10 +233,10 @@ MiddlewareRegistry.register(store => next => action => {
type: LOBBY_CHAT_MESSAGE,
message: action.message
}, lobbyMessageRecipient.id);
_persistSentPrivateMessage(store, lobbyMessageRecipient.id, action.message, true);
_persistSentPrivateMessage(store, lobbyMessageRecipient, action.message, true);
} else if (privateMessageRecipient) {
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message);
_persistSentPrivateMessage(store, privateMessageRecipient.id, action.message);
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message, 'body', isVisitorChatParticipant(privateMessageRecipient));
_persistSentPrivateMessage(store, privateMessageRecipient, action.message);
} else {
conference.sendTextMessage(action.message);
}
@@ -317,7 +323,7 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
JitsiConferenceEvents.MESSAGE_RECEIVED,
/* eslint-disable max-params */
(participantId: string, message: string, timestamp: number,
displayName: string, isGuest: boolean, messageId: string) => {
displayName: string, isFromVisitor: boolean, messageId: string) => {
/* eslint-enable max-params */
_onConferenceMessageReceived(store, {
// in case of messages coming from visitors we can have unknown id
@@ -325,7 +331,7 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
message,
timestamp,
displayName,
isGuest,
isFromVisitor,
messageId,
privateMessage: false });
@@ -350,13 +356,15 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
conference.on(
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
(participantId: string, message: string, timestamp: number, messageId: string) => {
(participantId: string, message: string, timestamp: number, messageId: string, displayName?: string, isFromVisitor?: boolean) => {
_onConferenceMessageReceived(store, {
participantId,
message,
timestamp,
displayName,
messageId,
privateMessage: true
privateMessage: true,
isFromVisitor
});
}
);
@@ -375,8 +383,8 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
* @returns {void}
*/
function _onConferenceMessageReceived(store: IStore,
{ displayName, isGuest, message, messageId, participantId, privateMessage, timestamp }: {
displayName?: string; isGuest?: boolean; message: string; messageId?: string;
{ displayName, isFromVisitor, message, messageId, participantId, privateMessage, timestamp }: {
displayName?: string; isFromVisitor?: boolean; message: string; messageId?: string;
participantId: string; privateMessage: boolean; timestamp: number; }
) {
@@ -390,7 +398,7 @@ function _onConferenceMessageReceived(store: IStore,
}
_handleReceivedMessage(store, {
displayName,
isGuest,
isFromVisitor,
participantId,
message,
privateMessage,
@@ -505,8 +513,8 @@ function getLobbyChatDisplayName(state: IReduxState, participantId: string) {
* @returns {void}
*/
function _handleReceivedMessage({ dispatch, getState }: IStore,
{ displayName, isGuest, lobbyChat, message, messageId, participantId, privateMessage, timestamp }: {
displayName?: string; isGuest?: boolean; lobbyChat: boolean; message: string;
{ displayName, isFromVisitor, lobbyChat, message, messageId, participantId, privateMessage, timestamp }: {
displayName?: string; isFromVisitor?: boolean; lobbyChat: boolean; message: string;
messageId?: string; participantId: string; privateMessage: boolean; timestamp: number; },
shouldPlaySound = true,
isReaction = false
@@ -525,9 +533,17 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
const participant = getParticipantById(state, participantId) || { local: undefined };
const localParticipant = getLocalParticipant(getState);
let displayNameToShow = lobbyChat
? getLobbyChatDisplayName(state, participantId)
: displayName || getParticipantDisplayName(state, participantId);
let _displayName, displayNameToShow;
if (lobbyChat) {
displayNameToShow = _displayName = getLobbyChatDisplayName(state, participantId);
} else if (isFromVisitor) {
_displayName = getVisitorDisplayName(state, displayName);
displayNameToShow = `${_displayName} ${i18next.t('visitors.chatIndicator')}`;
} else {
displayNameToShow = _displayName = getParticipantDisplayName(state, participantId);
}
const hasRead = participant.local || isChatOpen;
const timestampToDate = timestamp ? new Date(timestamp) : new Date();
const millisecondsTimestamp = timestampToDate.getTime();
@@ -536,12 +552,8 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
const shouldShowNotification = userSelectedNotifications?.['notify.chatMessages']
&& !hasRead && !isReaction && (!timestamp || lobbyChat);
if (isGuest) {
displayNameToShow = `${displayNameToShow} ${i18next.t('visitors.chatIndicator')}`;
}
dispatch(addMessage({
displayName: displayNameToShow,
displayName: _displayName,
hasRead,
participantId,
messageType: participant.local ? MESSAGE_TYPE_LOCAL : MESSAGE_TYPE_REMOTE,
@@ -551,7 +563,8 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
recipient: getParticipantDisplayName(state, localParticipant?.id ?? ''),
timestamp: millisecondsTimestamp,
messageId,
isReaction
isReaction,
isFromVisitor
}));
if (shouldShowNotification) {
@@ -574,6 +587,15 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
}
}
/**
* Interface for recipient objects used in private messaging.
*/
interface IRecipient {
id: string;
isVisitor?: boolean;
name?: string;
}
/**
* Persists the sent private messages as if they were received over the muc.
*
@@ -582,12 +604,12 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
* But those messages should be in the store as well, otherwise they don't appear in the chat window.
*
* @param {Store} store - The Redux store.
* @param {string} recipientID - The ID of the recipient the private message was sent to.
* @param {IRecipient} recipient - The recipient the private message was sent to.
* @param {string} message - The sent message.
* @param {boolean} isLobbyPrivateMessage - Is a lobby message.
* @returns {void}
*/
function _persistSentPrivateMessage({ dispatch, getState }: IStore, recipientID: string,
function _persistSentPrivateMessage({ dispatch, getState }: IStore, recipient: IRecipient,
message: string, isLobbyPrivateMessage = false) {
const state = getState();
const localParticipant = getLocalParticipant(state);
@@ -598,6 +620,13 @@ function _persistSentPrivateMessage({ dispatch, getState }: IStore, recipientID:
const displayName = getParticipantDisplayName(state, localParticipant.id);
const { lobbyMessageRecipient } = state['features/chat'];
const recipientName
= recipient.isVisitor
? getVisitorDisplayName(state, recipient.name)
: (isLobbyPrivateMessage
? lobbyMessageRecipient?.name
: getParticipantDisplayName(getState, recipient?.id));
dispatch(addMessage({
displayName,
hasRead: true,
@@ -606,20 +635,19 @@ function _persistSentPrivateMessage({ dispatch, getState }: IStore, recipientID:
message,
privateMessage: !isLobbyPrivateMessage,
lobbyChat: isLobbyPrivateMessage,
recipient: isLobbyPrivateMessage
? lobbyMessageRecipient?.name
: getParticipantDisplayName(getState, recipientID),
recipient: recipientName,
sentToVisitor: recipient.isVisitor,
timestamp: Date.now()
}));
}
/**
* Returns the ID of the participant who we may have wanted to send the message
* Returns the participant info for who we may have wanted to send the message
* that we're about to send.
*
* @param {Object} state - The Redux state.
* @param {Object} action - The action being dispatched now.
* @returns {string?}
* @returns {IRecipient?} - The recipient info or undefined if no notice should be shown.
*/
function _shouldSendPrivateMessageTo(state: IReduxState, action: AnyAction) {
if (action.ignorePrivacy) {
@@ -651,7 +679,11 @@ function _shouldSendPrivateMessageTo(state: IReduxState, action: AnyAction) {
if (lastMessage.privateMessage) {
// We show the notice if the last received message was private.
return lastMessage.participantId;
return {
id: lastMessage.participantId,
isFromVisitor: Boolean(lastMessage.isFromVisitor),
name: lastMessage.displayName
};
}
// But messages may come rapidly, we want to protect our users from mis-sending a message
@@ -666,7 +698,11 @@ function _shouldSendPrivateMessageTo(state: IReduxState, action: AnyAction) {
? recentPrivateMessages[0] : recentPrivateMessages[recentPrivateMessages.length - 1];
if (recentPrivateMessage) {
return recentPrivateMessage.participantId;
return {
id: recentPrivateMessage.participantId,
isFromVisitor: Boolean(recentPrivateMessage.isFromVisitor),
name: recentPrivateMessage.displayName
};
}
return undefined;

View File

@@ -1,6 +1,7 @@
import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes';
import { ILocalParticipant, IParticipant } from '../base/participants/types';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { IVisitorChatParticipant } from '../visitors/types';
import {
ADD_MESSAGE,
@@ -51,7 +52,7 @@ export interface IChatState {
} | ILocalParticipant;
messages: IMessage[];
nbUnreadMessages: number;
privateMessageRecipient?: IParticipant;
privateMessageRecipient?: IParticipant | IVisitorChatParticipant;
width: {
current: number;
userSet: number | null;
@@ -64,6 +65,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
const newMessage: IMessage = {
displayName: action.displayName,
error: action.error,
isFromVisitor: Boolean(action.isFromVisitor),
participantId: action.participantId,
isReaction: action.isReaction,
messageId: action.messageId,
@@ -73,6 +75,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
privateMessage: action.privateMessage,
lobbyChat: action.lobbyChat,
recipient: action.recipient,
sentToVisitor: Boolean(action.sentToVisitor),
timestamp: action.timestamp
};

View File

@@ -5,6 +5,7 @@ import { IStore } from '../app/types';
export interface IMessage {
displayName: string;
error?: Object;
isFromVisitor?: boolean;
isReaction: boolean;
lobbyChat: boolean;
message: string;
@@ -14,6 +15,7 @@ export interface IMessage {
privateMessage: boolean;
reactions: Map<string, Set<string>>;
recipient: string;
sentToVisitor?: boolean;
timestamp: number;
}
@@ -60,11 +62,6 @@ export interface IChatMessageProps extends WithTranslation {
*/
message: IMessage;
/**
* Whether the chat message menu is visible or not.
*/
shouldDisplayChatMessageMenu?: boolean;
/**
* Whether or not the avatar image of the participant which sent the message
* should be displayed.

View File

@@ -40,7 +40,6 @@ import {
NOTIFICATION_TIMEOUT_TYPE
} from '../notifications/constants';
import { showStartRecordingNotification } from '../recording/actions';
import { showSalesforceNotification } from '../salesforce/actions';
import { setToolboxEnabled } from '../toolbox/actions.any';
import { DISMISS_CALENDAR_NOTIFICATION } from './actionTypes';
@@ -164,7 +163,6 @@ function _conferenceJoined({ dispatch, getState }: IStore) {
}), 10 * 1000);
}
dispatch(showSalesforceNotification());
dispatch(showStartRecordingNotification());
_checkIframe(getState(), dispatch);

View File

@@ -8,7 +8,7 @@ import { isVpaasMeeting } from '../jaas/functions';
import DeepLinkingDesktopPage from './components/DeepLinkingDesktopPage';
import DeepLinkingMobilePage from './components/DeepLinkingMobilePage';
import NoMobileApp from './components/NoMobileApp';
import { _openDesktopApp } from './openDesktopApp';
import { _openDesktopApp } from './openDesktopApp.web';
/**
* Generates a deep linking URL based on the current window URL.

View File

@@ -1,7 +1,7 @@
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { OPEN_DESKTOP_APP } from './actionTypes';
import { openDesktopApp } from './functions';
import { openDesktopApp } from './functions.web';
/**
* Implements the middleware of the deep linking feature.

View File

@@ -1,3 +1,4 @@
import { executeAfterLoad } from '../app/functions.web';
import { IReduxState } from '../app/types';
import { URI_PROTOCOL_PATTERN } from '../base/util/uri';
@@ -16,7 +17,10 @@ export function _openDesktopApp(_state: Object) {
const { appScheme } = deeplinkingDesktop;
const regex = new RegExp(URI_PROTOCOL_PATTERN, 'gi');
window.location.href = window.location.href.replace(regex, `${appScheme}:`);
// This is needed to workaround https://issues.chromium.org/issues/41398687
executeAfterLoad(() => {
window.location.href = window.location.href.replace(regex, `${appScheme}:`);
});
return Promise.resolve(true);
}

View File

@@ -6,6 +6,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
@@ -78,6 +79,7 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
const visitors = useSelector(getVisitorsList);
const featureEnabled = useSelector(isVisitorsListEnabled);
const shouldDisplayList = useSelector(shouldDisplayCurrentVisitorsList);
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
@@ -109,9 +111,11 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
return null;
}
const filtered = visitors.filter(v =>
normalizeAccents(v.name).toLowerCase().includes(normalizeAccents(searchString).toLowerCase())
);
const filtered = visitors.filter(v => {
const displayName = v.name || defaultRemoteDisplayName || 'Fellow Jitster';
return normalizeAccents(displayName).toLowerCase().includes(normalizeAccents(searchString).toLowerCase());
});
// ListItem height is 56px including padding so the item size
// for virtualization needs to match it exactly to avoid clipping.
@@ -125,7 +129,7 @@ export default function CurrentVisitorsList({ searchString }: IProps) {
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMediaState = { MEDIA_STATE.NONE }
displayName = { v.name }
displayName = { v.name || defaultRemoteDisplayName || 'Fellow Jitster' }
participantID = { v.id }
videoMediaState = { MEDIA_STATE.NONE } />
</div>

View File

@@ -71,6 +71,7 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
container: {
boxSizing: 'border-box',
flex: 1,
overflowY: 'auto',
position: 'relative',
padding: `0 ${participantsPaneTheme.panePadding}px`,
display: 'flex',
@@ -78,6 +79,21 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
'&::-webkit-scrollbar': {
display: 'none'
},
// Temporary fix: Limit context menu width to prevent clipping
// TODO: Long-term fix would be to portal context menus outside the scrollable container
'& [class*="contextMenu"]': {
maxWidth: '285px',
'& [class*="contextMenuItem"]': {
whiteSpace: 'normal',
'& span': {
whiteSpace: 'normal',
wordBreak: 'break-word'
}
}
}
},

View File

@@ -91,6 +91,23 @@ export function getNotificationsMap(stateful: IStateful): { [key: string]: boole
}, {});
}
function normalizeCurrentLanguage(language: string) {
if (!language) {
return;
}
const [ country, lang ] = language.split('-');
const jitsiNormalized = `${country}${lang ?? ''}`;
if (LANGUAGES.includes(jitsiNormalized)) {
return jitsiNormalized;
}
if (LANGUAGES.includes(country)) {
return country;
}
}
/**
* Returns the properties for the "More" tab from settings dialog from Redux
* state.
@@ -102,7 +119,7 @@ export function getNotificationsMap(stateful: IStateful): { [key: string]: boole
export function getMoreTabProps(stateful: IStateful) {
const state = toState(stateful);
const stageFilmstripEnabled = isStageFilmstripEnabled(state);
const language = i18next.language || DEFAULT_LANGUAGE;
const language = normalizeCurrentLanguage(i18next.language) || DEFAULT_LANGUAGE;
const configuredTabs: string[] = interfaceConfig.SETTINGS_SECTIONS || [];
// when self view is controlled by the config we hide the settings

View File

@@ -1,4 +1,4 @@
import { Client } from '@stomp/stompjs';
import { Client, StompSubscription } from '@stomp/stompjs';
import logger from './logger';
import { WebsocketClient } from './websocket-client';
@@ -10,6 +10,9 @@ import { WebsocketClient } from './websocket-client';
export class VisitorsListWebsocketClient extends WebsocketClient {
private static client: VisitorsListWebsocketClient;
private _topicSubscription: StompSubscription | undefined;
private _queueSubscription: StompSubscription | undefined;
/**
* Creates a new instance of the VisitorsListWebsocketClient.
*
@@ -87,7 +90,7 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
const cachedDeltas: Array<{ n: string; r: string; s: string; }> = [];
// Subscribe first for deltas so we don't miss any while waiting for the initial list
this.stompClient.subscribe(topicEndpoint, deltaMessage => {
this._topicSubscription = this.stompClient.subscribe(topicEndpoint, deltaMessage => {
try {
const updates: Array<{ n: string; r: string; s: string; }> = JSON.parse(deltaMessage.body);
@@ -102,7 +105,7 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
});
// Subscribe for the initial list after topic subscription is active
const queueSubscription = this.stompClient.subscribe(queueEndpoint, message => {
this._queueSubscription = this.stompClient.subscribe(queueEndpoint, message => {
try {
const visitors: Array<{ n: string; r: string; }> = JSON.parse(message.body);
@@ -110,7 +113,11 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
initialReceived = true;
initialCallback(visitors);
queueSubscription.unsubscribe();
// Unsubscribe from queue after receiving initial list
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
this._queueSubscription = undefined;
}
if (cachedDeltas.length) {
deltaCallback(cachedDeltas);
@@ -124,4 +131,45 @@ export class VisitorsListWebsocketClient extends WebsocketClient {
this.stompClient.activate();
}
/**
* Unsubscribes from both topic and queue subscriptions.
*
* @returns {void}
*/
override unsubscribe(): void {
if (this._topicSubscription) {
this._topicSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list topic');
this._topicSubscription = undefined;
}
if (this._queueSubscription) {
this._queueSubscription.unsubscribe();
logger.debug('Unsubscribed from visitors list queue');
this._queueSubscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first.
*
* @returns {Promise}
*/
override disconnect(): Promise<any> {
if (!this.stompClient) {
return Promise.resolve();
}
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.debug(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}
}

View File

@@ -139,3 +139,21 @@ export function shouldDisplayCurrentVisitorsList(stateful: IStateful): boolean {
return isVisitorsListEnabled(state) && getVisitorsCount(state) > 0;
}
/**
*
* @param state
* @param displayName
* @returns
*/
/**
* Returns visitor's display name, falling back to the default remote display name
* from config, or 'Fellow Jitster' if neither is available.
*
* @param {IReduxState} state - The Redux state.
* @param {string} [displayName] - Optional display name to use if available.
* @returns {string} - The display name for a visitor.
*/
export function getVisitorDisplayName(state: IReduxState, displayName?: string): string {
return displayName || state['features/base/config'].defaultRemoteDisplayName || 'Fellow Jitster';
}

View File

@@ -9,6 +9,7 @@ import {
ENDPOINT_MESSAGE_RECEIVED,
UPDATE_CONFERENCE_METADATA
} from '../base/conference/actionTypes';
import { IConferenceMetadata } from '../base/conference/reducer';
import { SET_CONFIG } from '../base/config/actionTypes';
import { CONNECTION_FAILED } from '../base/connection/actionTypes';
import { connect, setPreferVisitor } from '../base/connection/actions';
@@ -176,7 +177,11 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
// let's subscribe for visitor waiting queue
const { room } = getState()['features/base/conference'];
const { disableBeforeUnloadHandlers = false } = getState()['features/base/config'];
const conferenceJid = `${room}@${hosts?.muc}`;
const beforeUnloadHandler = () => {
WebsocketClient.getInstance().disconnect();
};
WebsocketClient.getInstance()
.connect(`wss://${visitorsConfig?.queueService}/visitor/websocket`,
@@ -185,8 +190,12 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
if ('status' in msg && msg.status === 'live') {
logger.info('The conference is now live!');
WebsocketClient.getInstance().disconnect()
.then(() => {
window.removeEventListener(
disableBeforeUnloadHandlers ? 'unload' : 'beforeunload',
beforeUnloadHandler);
let delay = 0;
// now let's connect to meeting
@@ -213,20 +222,18 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
dispatch(setInVisitorsQueue(true));
});
/**
* Disconnecting the WebSocket client when the user closes the page.
*/
window.addEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', beforeUnloadHandler);
break;
}
case PARTICIPANT_UPDATED: {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
const { metadata } = getState()['features/base/conference'];
if (visitorsConfig?.queueService && isLocalParticipantModerator(getState)) {
const { metadata } = getState()['features/base/conference'];
if (metadata?.visitors?.live === false && !WebsocketClient.getInstance().isActive()) {
// when go live is available and false, we should subscribe
// to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
}
_handleQueueAndNotification(dispatch, getState, metadata);
break;
}
@@ -242,26 +249,8 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
case UPDATE_CONFERENCE_METADATA: {
const { metadata } = action;
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!visitorsConfig?.queueService) {
break;
}
if (isLocalParticipantModerator(getState)) {
if (metadata?.visitors?.live === false) {
if (!WebsocketClient.getInstance().isActive()) {
// if metadata go live changes to goLive false and local is moderator
// we should subscribe to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
_showNotLiveNotification(dispatch, getVisitorsInQueueCount(getState));
} else if (metadata?.visitors?.live) {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
WebsocketClient.getInstance().disconnect();
}
}
_handleQueueAndNotification(dispatch, getState, metadata);
break;
}
@@ -275,6 +264,38 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
return next(action);
});
/**
* Handles the queue connection and notification for visitors if needed.
*
* @param {IStore.dispatch} dispatch - The Redux dispatch function.
* @param {IStore.getState} getState - The Redux getState function.
* @param {IConferenceMetadata} metadata - The conference metadata.
* @returns {void}
*/
function _handleQueueAndNotification(
dispatch: IStore['dispatch'],
getState: IStore['getState'],
metadata: IConferenceMetadata | undefined): void {
const { visitors: visitorsConfig } = toState(getState)['features/base/config'];
if (!(visitorsConfig?.queueService && isLocalParticipantModerator(getState))) {
return;
}
if (metadata?.visitors?.live === false) {
if (!WebsocketClient.getInstance().isActive()) {
// if metadata go live changes to goLive false and local is moderator
// we should subscribe to the service if available to listen for waiting visitors
_subscribeQueueStats(getState(), dispatch);
}
_showNotLiveNotification(dispatch, getVisitorsInQueueCount(getState));
} else if (metadata?.visitors?.live) {
dispatch(hideNotification(VISITORS_NOT_LIVE_NOTIFICATION_ID));
WebsocketClient.getInstance().disconnect();
}
}
/**
* Shows a notification that the meeting is not live.
*

View File

@@ -12,7 +12,7 @@ import {
UPDATE_VISITORS_LIST,
VISITOR_PROMOTION_REQUEST
} from './actionTypes';
import { IPromotionRequest, IVisitor } from './types';
import { IPromotionRequest, IVisitorListParticipant } from './types';
const DEFAULT_STATE = {
count: 0,
@@ -22,7 +22,7 @@ const DEFAULT_STATE = {
showNotification: false,
supported: false,
promotionRequests: [],
visitors: [] as IVisitor[],
visitors: [] as IVisitorListParticipant[],
visitorsListSubscribed: false
};
@@ -34,7 +34,7 @@ export interface IVisitorsState {
inQueueCount?: number;
promotionRequests: IPromotionRequest[];
supported: boolean;
visitors: IVisitor[];
visitors: IVisitorListParticipant[];
visitorsListSubscribed: boolean;
}
ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_STATE, action): IVisitorsState => {
@@ -63,7 +63,7 @@ ReducerRegistry.register<IVisitorsState>('features/visitors', (state = DEFAULT_S
};
}
case UPDATE_VISITORS_IN_QUEUE_COUNT: {
if (state.count === action.count) {
if (state.inQueueCount === action.count) {
return state;
}

View File

@@ -3,7 +3,13 @@ export interface IPromotionRequest {
nick: string;
}
export interface IVisitor {
export interface IVisitorListParticipant {
id: string;
name: string;
}
export interface IVisitorChatParticipant {
id: string;
isVisitor: true;
name: string;
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Client } from '@stomp/stompjs';
import { Client, StompSubscription } from '@stomp/stompjs';
import logger from './logger';
@@ -28,6 +28,8 @@ export class WebsocketClient {
private _connectCount = 0;
private _subscription: StompSubscription | undefined;
/**
* WebsocketClient getInstance.
*
@@ -100,7 +102,7 @@ export class WebsocketClient {
this._connectCount++;
connectCallback?.();
this.stompClient.subscribe(endpoint, message => {
this._subscription = this.stompClient.subscribe(endpoint, message => {
try {
callback(JSON.parse(message.body));
} catch (e) {
@@ -113,7 +115,21 @@ export class WebsocketClient {
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from the current subscription.
*
* @returns {void}
*/
unsubscribe(): void {
if (this._subscription) {
this._subscription.unsubscribe();
logger.debug('Unsubscribed from WebSocket topic');
this._subscription = undefined;
}
}
/**
* Disconnects the current stomp client instance and clears it.
* Unsubscribes from any active subscriptions first if available.
*
* @returns {Promise}
*/
@@ -124,8 +140,11 @@ export class WebsocketClient {
const url = this.stompClient.brokerURL;
// Unsubscribe first (synchronous), then disconnect
this.unsubscribe();
return this.stompClient.deactivate().then(() => {
logger.info(`disconnected from: ${url}`);
logger.debug(`disconnected from: ${url}`);
this.stompClient = undefined;
});
}

View File

@@ -47,11 +47,15 @@ if (Platform.OS === 'ios') {
const globalNS = getJitsiMeetGlobalNS();
const connectionTimes = getJitsiMeetGlobalNSConnectionTimes();
// Used to check if the load event has been fired.
globalNS.hasLoaded = false;
// Used for automated performance tests.
connectionTimes['index.loaded'] = window.indexLoadedTime;
window.addEventListener('load', () => {
connectionTimes['window.loaded'] = window.loadedEventTime;
globalNS.hasLoaded = true;
});
document.addEventListener('DOMContentLoaded', () => {

View File

@@ -157,7 +157,14 @@ function start_av_moderation(room, mediaType, occupant)
room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = array{};
room.av_moderation[mediaType] = array();
-- add all current moderators to the new whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.role == 'moderator' and not ends_with(room_occupant.nick, '/focus') then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
-- We want to set startMuted policy in metadata, in case of new participants are joining to respect
-- it, that will be enforced by jicofo
@@ -166,7 +173,7 @@ function start_av_moderation(room, mediaType, occupant)
-- We want to keep the previous value of startMuted for this mediaType if av moderation is disabled
-- to be able to restore
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
av_moderation_startMuted_restore = startMutedMetadata[mediaType];
av_moderation_startMuted_restore[mediaType] = startMutedMetadata[mediaType];
room.av_moderation_startMuted_restore = av_moderation_startMuted_restore;
startMutedMetadata[mediaType] = true;
@@ -262,6 +269,12 @@ function on_message(event)
-- send message to all occupants
notify_occupants_enable(nil, enabled, room, occupant.nick, mediaType);
if enabled then
-- inform all moderators for the newly created whitelist
notify_whitelist_change(nil, true, room, mediaType);
end
return true;
elseif moderation_command.attr.jidToWhitelist then
local occupant_jid = moderation_command.attr.jidToWhitelist;
@@ -357,9 +370,13 @@ function occupant_joined(event)
-- NOTE for some reason event.occupant.role is not reflecting the actual occupant role (when changed
-- from allowners module) but iterating over room occupants returns the correct role
for _, room_occupant in room:each_occupant() do
-- if moderator send the whitelist
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
notify_whitelist_change(room_occupant.jid, false, room);
-- if it is a moderator, send the whitelist to every moderator
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end
end
end
@@ -367,14 +384,30 @@ end
-- when a occupant was granted moderator we need to update him with the whitelist
function occupant_affiliation_changed(event)
local room = event.room;
if not room.av_moderation or is_healthcheck_room(room.jid) or is_admin(event.jid)
or event.affiliation ~= 'owner' then
return;
end
-- in any enabled media type add the new moderator to the whitelist
for _, room_occupant in room:each_occupant() do
if room_occupant.bare_jid == event.jid then
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
end
end
end
end
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
if event.actor and event.affiliation == 'owner' and event.room.av_moderation then
local room = event.room;
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
notify_whitelist_change(occupant.jid, false, room);
if event.actor and event.affiliation == 'owner' then
-- notify all moderators for the new grant moderator and the change in whitelists
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
if room.av_moderation[mediaType] then
notify_whitelist_change(nil, true, room, mediaType);
end
end
end

View File

@@ -530,12 +530,56 @@ module:hook('muc-occupant-groupchat', function(event)
return true;
end, 55); -- prosody check for visitor's chat is prio 50, we want to override it
-- Private messaging support for visitors
module:hook('muc-private-message', function(event)
-- private messaging is forbidden
event.origin.send(st.error_reply(event.stanza, 'auth', 'forbidden',
'Private messaging is disabled on visitor nodes'));
return true;
end, 10);
local room, stanza = event.room, event.stanza;
local from = stanza.attr.from;
local to = stanza.attr.to;
local recipient_occupant = room:get_occupant_by_nick(to);
local recipient_domain = recipient_occupant and jid.host(recipient_occupant.bare_jid) or nil;
local sender_occupant = room:get_occupant_by_nick(from);
local sender_domain = sender_occupant and jid.host(sender_occupant.bare_jid) or nil;
if sender_domain == nil or recipient_domain == nil then
return false;
end
-- If both sender and recipient are local (on this vnode)
if sender_domain == local_domain and recipient_domain == local_domain then
event.origin.send(st.error_reply(event.stanza, 'auth', 'forbidden',
'Private messaging between visitors is disabled on visitor nodes'));
return false; -- Prevent sending the original message and stop further processing
end
-- If sender is local (visitor node) and recipient is from main prosody, forward to main prosody
if sender_domain == local_domain and recipient_domain == main_domain then
local original_to = stanza.attr.to;
local original_from = stanza.attr.from;
-- Add nick element for visitor identification
-- remove existing nick to avoid forgery
stanza:remove_children('nick', NICK_NS);
local nick_element = sender_occupant:get_presence():get_child('nick', NICK_NS);
if nick_element then
stanza:add_child(nick_element);
else
stanza:tag('nick', { xmlns = NICK_NS }):text('anonymous'):up();
end
-- Forward to main prosody, preserving the resource and original from
stanza.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain, jid.resource(to));
module:send(stanza);
return false; -- Prevent sending the original message and stop further processing
end
-- For main->visitor messages, let the default MUC handler process it
-- We don't need to do anything special
if sender_domain == main_domain and recipient_domain == local_domain then
return; -- Return nothing, let other handlers continue. The default MUC handler will process it.
end
return false; -- Prevent sending the original message and stop further processing
end, 100); -- Lower priority to run after other handlers
-- we calculate the stats on the configured interval (60 seconds by default)
module:hook_global('stats-update', function ()

View File

@@ -1,19 +1,23 @@
--- This module removes identity information from presence stanzas when the
--- hideDisplayNameForAll or hideDisplayNameForGuests options are enabled
--- for a room.
--- To be enabled under the main muc component
local filters = require 'util.filters';
local st = require 'util.stanza';
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local is_admin = util.is_admin;
local ends_with = util.ends_with;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local NICK_NS = 'http://jabber.org/protocol/nick';
-- we need to get the shared resource for joining moderators, as participants are marked as moderators
-- after joining which is after the filter for stanza/out, but we need to know will this participant be a moderator
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
--- Filter presence sent to non-moderator members of a room when the hideDisplayNameForGuests option is set.
function filter_stanza_out(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
@@ -21,48 +25,26 @@ function filter_stanza_out(stanza, session)
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
local shouldFilter = false;
if not room or room._data.hideDisplayNameForGuests ~= true then
return stanza;
end
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
if occupant then
if stanza.attr.from == internal_room_jid_match_rewrite(occupant.nick) then
-- we ignore self-presences, in this case and role will not be correct
return stanza;
end
if occupant.role ~= 'moderator' and not joining_moderator_participants[occupant.bare_jid] then
local st_clone = st.clone(stanza);
st_clone:remove_children('nick', NICK_NS);
return st_clone;
if room and (room._data.hideDisplayNameForGuests == true or room._data.hideDisplayNameForAll == true) then
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
-- don't touch self-presence
if occupant and stanza.attr.from ~= internal_room_jid_match_rewrite(occupant.nick) then
local isModerator = (occupant.role == 'moderator' or joining_moderator_participants[occupant.bare_jid]);
shouldFilter = room._data.hideDisplayNameForAll or not isModerator;
end
end
return stanza;
end
function filter_stanza_in(stanza, session)
if stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
if shouldFilter then
return filter_identity_from_presence(stanza);
else
return stanza;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
-- if hideDisplayNameForAll we want to drop any display name from the presence stanza
if not room or room._data.hideDisplayNameForAll ~= true then
return stanza;
end
stanza:remove_children('nick', NICK_NS);
return stanza;
end
function filter_session(session)
filters.add_filter(session, 'stanzas/out', filter_stanza_out, -100);
filters.add_filter(session, 'stanzas/in', filter_stanza_in, -100);
end
function module.load()

View File

@@ -19,6 +19,7 @@ local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local table_add = util.table_add;
local table_equals = util.table_equals;
local MUC_NS = 'http://jabber.org/protocol/muc';
local COMPONENT_IDENTITY_TYPE = 'room_metadata';
@@ -188,12 +189,15 @@ function on_message(event)
jsonData.data = res;
end
room.jitsiMetadata[jsonData.key] = jsonData.data;
local old_value = room.jitsiMetadata[jsonData.key];
if not table_equals(old_value, jsonData.data) then
room.jitsiMetadata[jsonData.key] = jsonData.data;
broadcastMetadata(room);
broadcastMetadata(room);
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
end
return true;
end
@@ -238,12 +242,35 @@ function process_main_muc_loaded(main_muc, host_module)
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
startMutedMetadata.audio = startMuted.attr.audio == 'true';
startMutedMetadata.video = startMuted.attr.video == 'true';
local audioNewValue = startMuted.attr.audio == 'true';
local videoNewValue = startMuted.attr.video == 'true';
local send_update = false;
room.jitsiMetadata.startMuted = startMutedMetadata;
if startMutedMetadata.audio ~= audioNewValue then
startMutedMetadata.audio = audioNewValue;
send_update = true;
end
if startMutedMetadata.video ~= videoNewValue then
startMutedMetadata.video = videoNewValue;
send_update = true;
end
host_module:fire_event('room-metadata-changed', { room = room; });
if send_update then
room.jitsiMetadata.startMuted = startMutedMetadata;
host_module:fire_event('room-metadata-changed', { room = room; });
end
end);
-- The the connection jid for authenticated users (like jicofo) stays the same,
-- so leaving and re-joining will result not sending metatadata again.
-- Make sure we clear the sent_initial_metadata entry for the occupant on leave.
host_module:hook("muc-occupant-left", function(event)
local room, occupant = event.room, event.occupant;
if room.sent_initial_metadata then
room.sent_initial_metadata[jid.bare(event.occupant.jid)] = nil;
end
end);
end
@@ -290,8 +317,7 @@ end
-- Send a message update for metadata before sending the first self presence
function filter_stanza(stanza, session)
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence'
or stanza.attr.type == 'unavailable' or ends_with(stanza.attr.from, '/focus') then
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence' or stanza.attr.type == 'unavailable' then
return stanza;
end

View File

@@ -12,12 +12,16 @@ local st = require 'util.stanza';
local jid = require 'util.jid';
local new_id = require 'util.id'.medium;
local util = module:require 'util';
local filter_identity_from_presence = util.filter_identity_from_presence;
local is_admin = util.is_admin;
local presence_check_status = util.presence_check_status;
local process_host_module = util.process_host_module;
local is_transcriber_jigasi = util.is_transcriber_jigasi;
local json = require 'cjson.safe';
-- Debug flag
local DEBUG = false;
local MUC_NS = 'http://jabber.org/protocol/muc';
-- required parameter for custom muc component prefix, defaults to 'conference'
@@ -97,6 +101,25 @@ local function send_visitors_iq(conference_service, room, type)
module:send(visitors_iq);
end
-- Filter out identity information (nick name, email, etc) from a presence stanza,
-- if the hideDisplayNameForGuests option for the room is set (note that the
-- hideDisplayNameForAll option is implemented in a diffrent way and does not
-- require filtering here)
-- This is applied to presence of main room participants before it is sent out to
-- vnodes.
local function filter_stanza_nick_if_needed(stanza, room)
if not stanza or stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
return stanza;
end
-- if hideDisplayNameForGuests we want to drop any display name from the presence stanza
if room and (room._data.hideDisplayNameForGuests or room._data.hideDisplayNameForAll) then
return filter_identity_from_presence(stanza);
end
return stanza;
end
-- an event received from visitors component, which receives iqs from jicofo
local function connect_vnode(event)
local room, vnode = event.room, event.vnode;
@@ -123,7 +146,7 @@ local function connect_vnode(event)
for _, o in room:each_occupant() do
if not is_admin(o.bare_jid) then
local fmuc_pr = st.clone(o:get_presence());
local fmuc_pr = filter_stanza_nick_if_needed(st.clone(o:get_presence()), room);
local user, _, res = jid.split(o.nick);
fmuc_pr.attr.to = jid.join(user, conference_service , res);
fmuc_pr.attr.from = o.jid;
@@ -206,7 +229,8 @@ end, 900);
process_host_module(main_muc_component_config, function(host_module, host)
-- detects presence change in a main participant and propagate it to the used visitor nodes
host_module:hook('muc-occupant-pre-change', function (event)
local room, stanza, occupant = event.room, event.stanza, event.dest_occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.dest_occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus and configured domains (used for jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
@@ -227,7 +251,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- when a main participant leaves inform the visitor nodes
host_module:hook('muc-occupant-left', function (event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- ignore configured domains (jibri and transcribers)
if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil
@@ -270,7 +295,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- detects new participants joining main room and sending them to the visitor nodes
host_module:hook('muc-occupant-joined', function (event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter focus, ignore configured domains (jibri and transcribers)
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil
@@ -294,7 +320,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
end);
-- forwards messages from main participants to vnodes
host_module:hook('muc-occupant-groupchat', function(event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
-- filter sending messages from transcribers/jibris to visitors
if not visitors_nodes[room.jid] then
@@ -314,7 +341,8 @@ process_host_module(main_muc_component_config, function(host_module, host)
-- receiving messages from visitor nodes and forward them to local main participants
-- and forward them to the rest of visitor nodes
host_module:hook('muc-occupant-groupchat', function(event)
local occupant, room, stanza = event.occupant, event.room, event.stanza;
local occupant, room, stanzaEv = event.occupant, event.room, event.stanza;
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
local to = stanza.attr.to;
local from = stanza.attr.from;
local from_vnode = jid.host(from);
@@ -352,6 +380,73 @@ process_host_module(main_muc_component_config, function(host_module, host)
return true;
end, 55); -- prosody check for unknown participant chat is prio 50, we want to override it
-- Handle private messages from visitor nodes to main participants
-- This routes forwarded private messages through the proper MUC system
host_module:hook('message/full', function(event)
local stanza = event.stanza;
-- Only handle chat messages (private messages)
if stanza.attr.type ~= 'chat' then
return; -- Let other handlers process non-chat messages
end
local to = stanza.attr.to;
-- Early return if this is not targeted at our MUC component
if jid.host(to) ~= main_muc_component_config then
return; -- Not for our MUC component, let other handlers process
end
local from = stanza.attr.from;
local from_host = jid.host(from);
local to_node = jid.node(to);
local to_resource = jid.resource(to);
-- Check if this is a private message from a known visitor node
local target_room_jid = jid.bare(to);
-- Early return if we don't have any visitor nodes for this room
if not (visitors_nodes[target_room_jid] and visitors_nodes[target_room_jid].nodes) then
return; -- No visitor nodes for this room, let default MUC handle it
end
-- Early return if the from_host is not a known visitor node
if not visitors_nodes[target_room_jid].nodes[from_host] then
-- This could be a main->visitor message, let it go through s2s
return; -- Not from a known visitor node, let default MUC handle it
end
-- At this point we know it's a visitor message, handle it
local room = prosody.hosts[main_muc_component_config].modules.muc.get_room_from_jid(target_room_jid);
if room then
-- Find the occupant
local occupant = room:get_occupant_by_nick(to);
if occupant then
-- Add addresses element (XEP-0033) to store original visitor JID for reply functionality
stanza:tag('addresses', { xmlns = 'http://jabber.org/protocol/address' })
:tag('address', { type = 'ofrom', jid = stanza.attr.from }):up()
:up();
-- Change from to be the main domain equivalent for proper client recognition
-- Use bare JID without resource
stanza.attr.from = jid.join(to_node, main_muc_component_config);
room:route_to_occupant(occupant, stanza);
return true;
else
module:log('warn', 'VISITOR PRIVATE MESSAGE: Occupant not found for %s', to);
end
else
module:log('warn', 'VISITOR PRIVATE MESSAGE: Room not found for %s', to);
end
return false;
end, 10); -- Normal priority since we're in the right place now
-- Main->visitor private messages work via s2s routing automatically
-- No special handling needed!
host_module:hook('muc-config-submitted/muc#roomconfig_roomsecret', function(event)
if event.status_codes['104'] then
local room = event.room;

View File

@@ -620,11 +620,14 @@ end
local function table_compare(old_table, new_table)
local removed = {}
local added = {}
local modified = {}
-- Find removed items (in old but not in new)
for id, _ in pairs(old_table) do
for id, value in pairs(old_table) do
if new_table[id] == nil then
table.insert(removed, id)
elseif new_table[id] ~= value then
table.insert(modified, id)
end
end
@@ -635,7 +638,20 @@ local function table_compare(old_table, new_table)
end
end
return removed, added
return removed, added, modified
end
local function table_equals(t1, t2)
if t1 == nil then
return t2 == nil;
end
if t2 == nil then
return t1 == nil;
end
local removed, added, modified = table_compare(t1, t2);
return next(removed) == nil and next(added) == nil and next(modified) == nil
end
-- Splits a string using delimiter
@@ -685,11 +701,35 @@ local function is_admin(_jid)
return false;
end
-- Filter out identity information (nick name, email, etc) from a presence stanza.
local function filter_identity_from_presence(orig_stanza)
local stanza = st.clone(orig_stanza);
stanza:remove_children('nick', 'http://jabber.org/protocol/nick');
stanza:remove_children('email');
stanza:remove_children('stats-id');
local identity = stanza:get_child('identity');
if identity then
local user = identity:get_child('user');
local name = identity:get_child('name');
if user then
user:remove_children('email');
user:remove_children('name');
end
if name then
name:remove_children('name'); -- Remove name with no namespace
end
end
return stanza;
end
return {
OUTBOUND_SIP_JIBRI_PREFIXES = OUTBOUND_SIP_JIBRI_PREFIXES;
INBOUND_SIP_JIBRI_PREFIXES = INBOUND_SIP_JIBRI_PREFIXES;
RECORDER_PREFIXES = RECORDER_PREFIXES;
extract_subdomain = extract_subdomain;
filter_identity_from_presence = filter_identity_from_presence;
is_admin = is_admin;
is_feature_allowed = is_feature_allowed;
is_jibri = is_jibri;
@@ -722,4 +762,5 @@ return {
table_compare = table_compare;
table_shallow_copy = table_shallow_copy;
table_find = table_find;
table_equals = table_equals;
};

View File

@@ -107,7 +107,7 @@ export default class WebhookProxy {
* @param eventType
* @param timeout
*/
async waitForEvent(eventType: string, timeout = 4000): Promise<any> {
async waitForEvent(eventType: string, timeout = 120000): Promise<any> {
// we create the error here so we have a meaningful stack trace
const error = new Error(`Timeout waiting for event:${eventType}`);

View File

@@ -140,7 +140,7 @@ describe('Chat', () => {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('CHAT_UPLOADED', 20000);
} = await webhooksProxy.waitForEvent('CHAT_UPLOADED');
expect('CHAT_UPLOADED').toBe(event.eventType);
expect(event.data.preAuthenticatedLink).toBeDefined();

View File

@@ -235,6 +235,8 @@ describe('Participants presence', () => {
await p1.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().addEventListener('participantKickedOut');
await p2.getIframeAPI().clearEventResults('videoConferenceLeft');
await p2.getIframeAPI().addEventListener('videoConferenceLeft');
await p1.getIframeAPI().executeCommand('kickParticipant', p2EpId);

View File

@@ -140,7 +140,7 @@ describe('Transcriptions', () => {
// sometimes events are not immediately received,
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED', 10000);
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
if (webhooksProxy) {
const event: {
@@ -148,7 +148,7 @@ describe('Transcriptions', () => {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED', 20000);
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect('TRANSCRIPTION_UPLOADED').toBe(event.eventType);
expect(event.data.preAuthenticatedLink).toBeDefined();
@@ -186,7 +186,7 @@ async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksPr
stable: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_CHUNK_RECEIVED', 60000);
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_CHUNK_RECEIVED');
expect('TRANSCRIPTION_CHUNK_RECEIVED').toBe(event.eventType);

View File

@@ -64,7 +64,7 @@ describe('Recording', () => {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_STARTED', 15000);
} = await webhooksProxy.waitForEvent('LIVE_STREAM_STARTED');
expect('LIVE_STREAM_STARTED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
@@ -88,7 +88,7 @@ describe('Recording', () => {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('LIVE_STREAM_ENDED', 20000);
} = await webhooksProxy.waitForEvent('LIVE_STREAM_ENDED');
expect('LIVE_STREAM_ENDED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
@@ -127,7 +127,7 @@ async function testRecordingStarted(command: boolean) {
const recordingEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_STARTED', 15000);
} = await webhooksProxy.waitForEvent('RECORDING_STARTED');
expect('RECORDING_STARTED').toBe(recordingEvent.eventType);
expect(recordingEvent.customerId).toBe(customerId);
@@ -166,7 +166,7 @@ async function testRecordingStopped(command: boolean) {
const liveStreamEvent: {
customerId: string;
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_ENDED', 20000);
} = await webhooksProxy.waitForEvent('RECORDING_ENDED');
expect('RECORDING_ENDED').toBe(liveStreamEvent.eventType);
expect(liveStreamEvent.customerId).toBe(customerId);
@@ -178,7 +178,7 @@ async function testRecordingStopped(command: boolean) {
participants: Array<string>;
};
eventType: string;
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED', 20000);
} = await webhooksProxy.waitForEvent('RECORDING_UPLOADED');
const jwtPayload = ctx.data[`${p1.name}-jwt-payload`];