Compare commits

..

19 Commits

Author SHA1 Message Date
Boris Grozev
e94153df16 test: Enable retries for the kick test, it's failing sporadically. 2026-02-18 17:38:06 -06:00
Mihaela Dumitru
3c85aa2350 fix(ui): add semantic tokens for svg fills and preview (#16962) 2026-02-18 20:11:04 +02:00
Calin-Teodor
c9a220e1c3 fix(android/sdk): prevent inflated inset values for top and bottom 2026-02-18 12:31:04 +02:00
Mihaela Dumitru
5f11e2e9f4 fix(ui): more semantic tokens (#16955) 2026-02-18 10:43:39 +02:00
Calin-Teodor
45ee515b6b fix(base/ui): update welcome page navigation colors 2026-02-16 16:42:11 +02:00
root
56f7792604 fix: insecure-document-method-36 2026-02-13 13:16:22 +01:00
Mihaela Dumitru
230559c493 fix(ui): hide input placeholder on focus (#16932) 2026-02-12 12:00:46 +02:00
Stephan Paternotte
900bece988 lang: Update Dutch translations (#16931)
feat(file-sharing): Adds tooltip to upload button.
2026-02-11 19:52:37 -06:00
RidaElmaaroufi
a06a338de5 fix: enable local recording when embedded in a same-domain iframe (#16900) 2026-02-11 12:08:35 +01:00
Christoph Settgast
eb1e4f01e7 lang: update German translation 2026-02-10 15:23:33 -06:00
bgrozev
641a401b4b fix(tests): Add expectations for async transcriptions. (#16925) 2026-02-10 11:07:15 -06:00
Дамян Минков
6e3c256905 feat(file-sharing): Adds tooltip to upload button. (#16924)
* feat(file-sharing): Adds tooltip to upload button.

* squash: Change message.
2026-02-10 09:24:19 -06:00
damencho
36a755776e feat(invite): Respects dial-in field from metadata. 2026-02-10 08:42:37 -06:00
Damien Fetis
7bd38be8a4 lang: Update French labels (#16922) 2026-02-10 08:42:29 -06:00
Jaya Allamsetty
c88bfa1b9a fix(filmstrip) Excludes partially visible tiles for dominant speaker slot. 2026-02-09 17:59:27 -05:00
bgrozev
6a443b0f8f fix: Do not access "room" if undefined, improve logs. (#16917) 2026-02-09 15:52:47 -06:00
Mihaela Dumitru
b4cc466608 feat(files): add external api events for upload/delete (#16908) 2026-02-09 14:10:42 +02:00
Jaya Allamsetty
bde8dca825 fix(filmstrip) Ensures dominant speaker is always visible in filmstrip (#16901)
* fix(filmstrip) Ensures dominant speaker is always visible in filmstrip
2026-02-06 14:29:32 -05:00
Hristo Terezov
2ab86cffb8 feat(draggable-panels): Enable touch-screen support
Adds touch-screen support for resizing filmstrip and chat panels to enable tablet and touch-laptop users to adjust panel widths. Previously, drag handles only worked with mouse hover, making panels non-resizable on touch devices.

Changes:
- Implement Pointer Events API for unified mouse/touch handling
- Add touch device detection with screen size threshold
- Make drag handles always visible on touch devices with padding for easier tapping
- Maintain identical visual layout between touch and non-touch versions

Touch devices with sufficiently large screens now have fully functional drag handles with appropriate hit targets while smaller devices remain disabled to preserve mobile UX.
2026-02-06 09:48:44 -06:00
53 changed files with 1231 additions and 370 deletions

View File

@@ -96,23 +96,53 @@ public class JitsiMeetActivity extends AppCompatActivity
public static void addTopBottomInsets(@NonNull Window w, @NonNull View v) {
// Enable edge-to-edge mode
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(w, false);
// Make system bars transparent so content is visible underneath
w.setStatusBarColor(android.graphics.Color.TRANSPARENT);
w.setNavigationBarColor(android.graphics.Color.TRANSPARENT);
View decorView = w.getDecorView();
decorView.post(() -> {
WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(decorView);
if (insets != null) {
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
params.topMargin = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top;
params.bottomMargin = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
// Get display metrics for calculating density-independent caps
final android.util.DisplayMetrics metrics = v.getContext().getResources().getDisplayMetrics();
final int screenHeight = metrics.heightPixels;
final float density = metrics.density;
// Listen for window inset changes
// when system bars visibility is toggled or when the device rotates
ViewCompat.setOnApplyWindowInsetsListener(decorView, (view, windowInsets) -> {
// Get the actual inset values reported by the system
int statusBarInset = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top;
int navBarInset = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
// Calculate maximum allowed inset values to prevent device-specific bugs
final int maxTopInset = Math.min((int)(60 * density), (int)(screenHeight * 0.10));
final int maxBottomInset = Math.min((int)(120 * density), (int)(screenHeight * 0.10));
int topInset = Math.min(statusBarInset, maxTopInset);
int bottomInset = Math.min(navBarInset, maxBottomInset);
// Apply calculated insets
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
// Update margins only if they've changed
if (params.topMargin != topInset || params.bottomMargin != bottomInset) {
params.topMargin = topInset;
params.bottomMargin = bottomInset;
v.setLayoutParams(params);
decorView.setOnApplyWindowInsetsListener((view, windowInsets) -> {
view.setBackgroundColor(JitsiMeetView.BACKGROUND_COLOR);
return windowInsets;
});
}
view.setBackgroundColor(JitsiMeetView.BACKGROUND_COLOR);
// Return CONSUMED to prevent double-application of margins
return WindowInsetsCompat.CONSUMED;
});
// Manually trigger the inset listener to apply margins immediately
ViewCompat.requestApplyInsets(decorView);
}
// Overrides
@@ -136,10 +166,11 @@ public class JitsiMeetActivity extends AppCompatActivity
setContentView(R.layout.activity_jitsi_meet);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
&& getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
addTopBottomInsets(getWindow(), findViewById(android.R.id.content));
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM
&& getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
addTopBottomInsets(getWindow(), findViewById(android.R.id.content));
}
this.jitsiView = findViewById(R.id.jitsiView);

View File

@@ -45,7 +45,7 @@ body {
.jitsi-icon {
&-default svg {
fill: var(--icon-default-color, white);
fill: var(--icon-svg-fill, white);
}
}

View File

@@ -2,8 +2,8 @@
.reactions-menu {
width: 330px;
background: #242528;
box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.6), 0px 0px 4px 1px rgba(0, 0, 0, 0.25);
background: var(--reactions-menu-background, #242528);
box-shadow: 0px 3px 16px var(--reactions-menu-box-shadow-1, rgba(0, 0, 0, 0.6)), 0px 0px 4px 1px var(--reactions-menu-box-shadow-2, rgba(0, 0, 0, 0.25));
border-radius: 6px;
padding: 16px;
@@ -14,7 +14,7 @@
top: 3px;
& .toolbox-icon.toggled {
background-color: #000000;
background-color: var(--reactions-menu-button-toggled, #000000);
}
}
}

View File

@@ -106,7 +106,7 @@
}
#preview {
background: #040404;
background: var(--prejoin-preview-background, #040404);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -227,6 +227,9 @@
"video_ssrc": "Video-SSRC:",
"yes": "Ja"
},
"customPanel": {
"close": "Schließen"
},
"dateUtils": {
"earlier": "Früher",
"today": "Heute",
@@ -589,6 +592,7 @@
"newFileNotification": "{{ participantName }} hat Datei '{{ fileName }}' hochgeladen",
"removeFile": "Entfernen",
"removeFileSuccess": "Datei erfolgreich entfernt",
"uploadDisabled": "Keine Berechtigung, Dateien hochzuladen. Bitte bei der Moderation anfragen.",
"uploadFailedDescription": "Bitte versuchen Sie es erneut.",
"uploadFailedTitle": "Dateiupload fehlgeschlagen",
"uploadFile": "Datei hochladen"
@@ -1315,6 +1319,7 @@
"chat": "Chatfenster öffnen / schließen",
"clap": "Klatschen",
"closeChat": "Chat schließen",
"closeCustomPanel": "Schließen",
"closeMoreActions": "„Weitere Einstellungen“ schließen",
"closeParticipantsPane": "Anwesendenliste schließen",
"closedCaptions": "Untertitel",
@@ -1420,9 +1425,11 @@
"chat": "Chat öffnen / schließen",
"clap": "Klatschen",
"closeChat": "Chat schließen",
"closeCustomPanel": "Schließen",
"closeParticipantsPane": "Anwesenheitsliste schließen",
"closeReactionsMenu": "Interaktionsmenü schließen",
"closedCaptions": "Untertitel",
"copilot": "Copilot",
"disableNoiseSuppression": "Rauschunterdrückung deaktivieren",
"disableReactionSounds": "Sie können die Interaktionstöne für diese Konferenz deaktivieren",
"documentClose": "Geteiltes Dokument schließen",

View File

@@ -114,6 +114,9 @@
"error": "Erreur : votre message n'a pas été envoyé. Raison : {{error}}",
"everyone": "Tout le monde",
"fieldPlaceHolder": "Tapez votre message ici",
"fileAccessibleTitle": "{{user}} a téléversé un fichier",
"fileAccessibleTitleMe": "jai téléversé un fichier",
"fileDeleted": "Un fichier a été supprimé",
"guestsChatIndicator": "(invité)",
"lobbyChatMessageTo": "Message de salle d'attente à {{recipient}}",
"message": "Message",
@@ -123,8 +126,16 @@
"messagebox": "Envoyer un message",
"newMessages": "Nouveaux messages",
"nickname": {
"featureChat": "chat",
"featureClosedCaptions": "sous-titres",
"featureFileSharing": "partage de fichiers",
"featurePolls": "sondages",
"popover": "Choisissez un pseudonyme",
"title": "Entrez un pseudonyme pour utiliser le chat",
"titleWith1Features": "Entrez un pseudonyme pour utiliser {{feature1}}",
"titleWith2Features": "Entrez un pseudonyme pour utiliser {{feature1}} et {{feature2}}",
"titleWith3Features": "Entrez un pseudonyme pour utiliser {{feature1}}, {{feature2}} et {{feature3}}",
"titleWith4Features": "Entrez un pseudonyme pour utiliser {{feature1}}, {{feature2}}, {{feature3}} et {{feature4}}",
"titleWithCC": "Entrez un pseudonyme pour utiliser le chat et les sous-titres",
"titleWithPolls": "Entrez un pseudonyme pour utiliser le chat et les sondages",
"titleWithPollsAndCC": "Entrez un pseudonyme pour utiliser le chat, les sondages et les sous-titres",
@@ -216,6 +227,9 @@
"video_ssrc": "Video SSRC :",
"yes": "oui"
},
"customPanel": {
"close": "Fermer"
},
"dateUtils": {
"earlier": "Plus tôt",
"today": "Aujourd'hui",
@@ -522,6 +536,7 @@
"tokenAuthFailedWithReasons": "Désolé, vous nêtes pas autorisé à rejoindre lappel. La raison possible : {{reason}}.",
"tokenAuthUnsupported": "Token URL n'est pas supporté.",
"transcribing": "Transcription",
"unauthenticatedAccessDisabled": "Cet appel nécessite une authentification. Veuillez vous connecter pour continuer.",
"unlockRoom": "Supprimer le $t(lockRoomPassword) de la réunion",
"user": "Utilisateur",
"userIdentifier": "Identifiant utilisateur",
@@ -569,10 +584,12 @@
"downloadStarted": "Téléchargement de fichier démarré",
"dragAndDrop": "Glissez et déposez des fichiers ici ou n'importe où sur l'écran",
"fileAlreadyUploaded": "Le fichier a déjà été téléchargé vers cette réunion.",
"fileRemovedByOther": "Votre fichier « {{ fileName }} » a été supprimé",
"fileTooLargeDescription": "Veuillez vous assurer que le fichier ne dépasse pas {{ maxFileSize }}.",
"fileTooLargeTitle": "Le fichier sélectionné est trop volumineux",
"fileUploadProgress": "Progression du téléchargement de fichier",
"fileUploadedSuccessfully": "Fichier téléchargé avec succès",
"newFileNotification": "{{ participantName }} a partagé « {{ fileName }} »",
"removeFile": "Supprimer",
"removeFileSuccess": "Fichier supprimé avec succès",
"uploadFailedDescription": "Veuillez réessayer.",
@@ -964,6 +981,9 @@
"by": "Par {{ name }}",
"closeButton": "Fermer le sondage",
"create": {
"accessibilityLabel": {
"send": "Envoyer le sondage"
},
"addOption": "Ajouter une option",
"answerPlaceholder": "Option {{index}}",
"cancel": "Annuler",
@@ -972,8 +992,7 @@
"pollQuestion": "Question du sondage",
"questionPlaceholder": "Poser une question",
"removeOption": "Supprimer l'option",
"save": "Enregistrer",
"send": "Envoyer"
"save": "Enregistrer"
},
"errors": {
"notUniqueOption": "Les options doivent être uniques"
@@ -1299,6 +1318,7 @@
"chat": "Afficher / Masquer la discussion instantanée",
"clap": "Applaudir",
"closeChat": "Fermer la discussion instantanée",
"closeCustomPanel": "Fermer",
"closeMoreActions": "Fermer le menu plus d'actions",
"closeParticipantsPane": "Fermer le panneau des participants",
"closedCaptions": "Sous-titres",
@@ -1404,9 +1424,11 @@
"chat": "Ouvrir / Fermer le chat",
"clap": "Applaudir",
"closeChat": "Fermer le chat",
"closeCustomPanel": "Fermer",
"closeParticipantsPane": "Fermer le panneau des participants",
"closeReactionsMenu": "Fermer le menu réactions",
"closedCaptions": "Sous-titres",
"copilot": "Copilot",
"disableNoiseSuppression": "Arrêter la suppression du bruit",
"disableReactionSounds": "Vous pouvez interdire les réactions sonores à cette réunion",
"documentClose": "Fermer le document partagé",
@@ -1421,6 +1443,7 @@
"exitFullScreen": "Quitter le mode plein écran",
"exitTileView": "Quitter le mode mosaïque",
"feedback": "Laisser des commentaires",
"fileSharing": "Partage de fichiers",
"giphy": "Activer/désactiver le menu GIPHY",
"hangup": "Quitter",
"help": "Aide",
@@ -1456,6 +1479,7 @@
"openReactionsMenu": "Ouvrir le menu Réactions",
"participants": "Participants",
"pip": "Entrer en mode Picture-in-Picture",
"polls": "Sondages",
"privateMessage": "Envoyer un message privé",
"profile": "Éditer votre profil",
"raiseHand": "Lever / Baisser la main",

View File

@@ -1,7 +1,7 @@
{
"addPeople": {
"accessibilityLabel": {
"meetingLink": "Meeting link: {{url}}"
"meetingLink": "Meeting-link: {{url}}"
},
"add": "Uitnodigen",
"addContacts": "Nodig uw contacten uit",
@@ -504,7 +504,7 @@
"sharedVideoDialogError": "Fout: Ongeldige of verboden URL",
"sharedVideoLinkPlaceholder": "YouTube-link of directe video-link",
"show": "Weergeven",
"start": "Start ",
"start": "Starten ",
"startLiveStreaming": "Livestream starten",
"startRecording": "Opname starten",
"startRemoteControlErrorMessage": "Er is een fout opgetreden tijdens het starten van de sessie van extern beheer.",
@@ -592,6 +592,7 @@
"newFileNotification": "{{ participantName }} deelde '{{ fileName }}'",
"removeFile": "Verwijderen",
"removeFileSuccess": "Bestand met succes verwijderd",
"uploadDisabled": "Bestanden uploaden is niet toegestaan. Vraag een moderator om toestemming voor die handeling.",
"uploadFailedDescription": "Probeer het nog eens.",
"uploadFailedTitle": "Upload is mislukt",
"uploadFile": "Bestand delen"
@@ -888,7 +889,7 @@
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) verwijderd door een andere deelnemer",
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) ingesteld door een ander deelnemer",
"raiseHandAction": "Hand opsteken",
"raisedHand": "Wilgraag deelnemen.",
"raisedHand": "Wil graag deelnemen.",
"raisedHands": "{{participantName}} en {{raisedHands}} meer mensen",
"reactionSounds": "Geluiden uitschakelen",
"reactionSoundsForAll": "Geluiden uitschakelen voor iedereen",
@@ -1096,7 +1097,7 @@
"setEmailLabel": "Gravatar e-mailadres",
"title": "Profiel"
},
"raisedHand": "Zou graag willen spreken",
"raisedHand": "Wil graag spreken",
"raisedHandsLabel": "Aantal handen omhoog",
"record": {
"already": {
@@ -1630,30 +1631,30 @@
"chatIndicator": "(toeschouwer)",
"joinMeeting": {
"description": "U bent momenteel een toeschouwer bij deze conferentie.",
"raiseHand": "Raise your hand",
"title": "Joining meeting",
"wishToSpeak": "If you wish to speak, please raise your hand below and wait for the moderator's approval."
"raiseHand": "Hand opsteken",
"title": "Deelnemen aan vergadering",
"wishToSpeak": "Als u wilt spreken, steek dan hieronder uw hand op en wacht op goedkeuring van de moderator."
},
"labelTooltip": "Number of viewers: {{count}}",
"labelTooltip": "Aantal toeschouwers: {{count}}",
"notification": {
"demoteDescription": "Sent here by {{actor}}, raise your hand to participate",
"noMainParticipantsDescription": "A participant needs to start the meeting. Please try again in a bit.",
"noMainParticipantsTitle": "This meeting hasn't started yet.",
"noVisitorLobby": "You cannot join while there is a lobby enabled for the meeting.",
"notAllowedPromotion": "A participant needs to allow your request first.",
"requestToJoin": "Hand Raised",
"requestToJoinDescription": "Your request was sent to the moderators. Hang tight!",
"title": "You are a viewer in the meeting"
"demoteDescription": "Hierheen gestuurd door {{actor}}, steek uw hand op om deel te nemen",
"noMainParticipantsDescription": "Een deelnemer moet de vergadering starten. Probeer het over een tijdje opnieuw.",
"noMainParticipantsTitle": "Deze vergadering is nog niet begonnen.",
"noVisitorLobby": "U kunt niet deelnemen terwijl er een wachtruimte is ingeschakeld voor de vergadering.",
"notAllowedPromotion": "Een deelnemer moet eerst uw verzoek toestaan.",
"requestToJoin": "Hand opgestoken",
"requestToJoinDescription": "Uw verzoek is naar de moderators gestuurd. Even geduld!",
"title": "U bent een toeschouwer in de vergadering"
},
"waitingMessage": "You'll join the meeting as soon as it is live!"
"waitingMessage": "U zult deelnemen aan de vergadering zodra deze live is!"
},
"volumeSlider": "Volume slider",
"volumeSlider": "Volume-schuifknop",
"welcomepage": {
"accessibilityLabel": {
"join": "Tik om deel te nemen",
"roomname": "Voer naam van ruimte in"
},
"addMeetingName": "Add Meeting name",
"addMeetingName": "Geef de vergadering een naam",
"appDescription": "U kunt nu videochatten met het gehele team. Nodig uit wie u maar wilt. {{app}} is een volledig versleutelde, 100% open-source oplossing voor videovergaderingen, die u wanneer u maar wilt gratis kunt gebruiken — zonder dat u een account nodig hebt.",
"audioVideoSwitch": {
"audio": "Spraak",
@@ -1673,16 +1674,16 @@
"join": "AANMAKEN / DEELNEMEN",
"logo": {
"calendar": "Agenda logo",
"desktopPreviewThumbnail": "Desktop preview thumbnail",
"desktopPreviewThumbnail": "Bureaubladvoorbeeld miniatuur",
"googleLogo": "Google logo",
"logoDeepLinking": "Jitsi meet logo",
"microsoftLogo": "Microsoft logo",
"policyLogo": "Policy logo"
},
"meetingsAccessibilityLabel": "Meetings",
"mobileDownLoadLinkAndroid": "Download mobile app for Android",
"mobileDownLoadLinkFDroid": "Download mobile app for F-Droid",
"mobileDownLoadLinkIos": "Download mobile app for iOS",
"meetingsAccessibilityLabel": "Vergaderingen",
"mobileDownLoadLinkAndroid": "Download mobiele app voor Android",
"mobileDownLoadLinkFDroid": "Download mobiele app voor F-Droid",
"mobileDownLoadLinkIos": "Download mobiele app voor iOS",
"moderatedMessage": "Of <a href=\"{{url}}\" rel=\"noopener noreferrer\" target=\"_blank\">boek een vergadering URL</a> van tevoren waar u de enige moderator bent.",
"privacy": "Privacy",
"recentList": "Recent",

View File

@@ -592,6 +592,7 @@
"newFileNotification": "{{ participantName }} shared '{{ fileName }}'",
"removeFile": "Remove",
"removeFileSuccess": "File removed successfully",
"uploadDisabled": "Not allowed to upload files. Ask a moderator for permission rights for that operation.",
"uploadFailedDescription": "Please try again.",
"uploadFailedTitle": "Upload failed",
"uploadFile": "Share file"

View File

@@ -2253,6 +2253,32 @@ class API {
});
}
/**
* Notify the external application that a file has been uploaded.
*
* @param {Object} fileMetadata - The file metadata.
* @returns {void}
*/
notifyFileUploaded(fileMetadata) {
this._sendEvent({
name: 'file-uploaded',
file: fileMetadata
});
}
/**
* Notify the external application that a file has been deleted.
*
* @param {string} fileId - The ID of the deleted file.
* @returns {void}
*/
notifyFileDeleted(fileId) {
this._sendEvent({
name: 'file-deleted',
fileId
});
}
/**
* Notify the external application that the audio or video is being shared by a participant.
*

View File

@@ -133,6 +133,8 @@ const events = {
'face-landmark-detected': 'faceLandmarkDetected',
'feedback-submitted': 'feedbackSubmitted',
'feedback-prompt-displayed': 'feedbackPromptDisplayed',
'file-deleted': 'fileDeleted',
'file-uploaded': 'fileUploaded',
'filmstrip-display-changed': 'filmstripDisplayChanged',
'incoming-message': 'incomingMessage',
'knocking-participant': 'knockingParticipant',

View File

@@ -3,7 +3,7 @@
import Logger from '@jitsi/logger';
import $ from 'jquery';
import React from 'react';
import { createRoot } from 'react-dom/client';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
@@ -166,8 +166,7 @@ export default class LargeVideoManager {
this.removePresenceLabel();
this._avatarRoot?.unmount();
this._avatarRoot = null;
ReactDOM.unmountComponentAtNode(this._dominantSpeakerAvatarContainer);
this.container.style.display = 'none';
}
@@ -519,16 +518,14 @@ export default class LargeVideoManager {
* Updates the src of the dominant speaker avatar
*/
updateAvatar() {
if (!this._avatarRoot) {
this._avatarRoot = createRoot(this._dominantSpeakerAvatarContainer);
}
this._avatarRoot.render(
ReactDOM.render(
<Provider store = { APP.store }>
<Avatar
id = "dominantSpeakerAvatar"
participantId = { this.id }
size = { 200 } />
</Provider>
</Provider>,
this._dominantSpeakerAvatarContainer
);
}
@@ -562,18 +559,15 @@ export default class LargeVideoManager {
const presenceLabelContainer = document.getElementById('remotePresenceMessage');
if (presenceLabelContainer) {
if (!this._presenceLabelRoot) {
this._presenceLabelRoot = createRoot(presenceLabelContainer);
}
this._presenceLabelRoot.render(
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<PresenceLabel
participantID = { id }
className = 'presence-label' />
</I18nextProvider>
</Provider>
);
</Provider>,
presenceLabelContainer);
}
}
@@ -583,8 +577,11 @@ export default class LargeVideoManager {
* @returns {void}
*/
removePresenceLabel() {
this._presenceLabelRoot?.unmount();
this._presenceLabelRoot = null;
const presenceLabelContainer = document.getElementById('remotePresenceMessage');
if (presenceLabelContainer) {
ReactDOM.unmountComponentAtNode(presenceLabelContainer);
}
}
/**

View File

@@ -4,7 +4,7 @@
import Logger from '@jitsi/logger';
import $ from 'jquery';
import React from 'react';
import { createRoot } from 'react-dom/client';
import ReactDOM from 'react-dom';
import { browser } from '../../../react/features/base/lib-jitsi-meet';
import { FILMSTRIP_BREAKPOINT } from '../../../react/features/filmstrip/constants';
@@ -659,12 +659,7 @@ export class VideoContainer extends LargeContainer {
return;
}
const container = document.getElementById('largeVideoBackgroundContainer');
if (!this._backgroundRoot) {
this._backgroundRoot = createRoot(container);
}
this._backgroundRoot.render(
ReactDOM.render(
<LargeVideoBackground
hidden = { this._hideBackground || this._isHidden }
mirror = {
@@ -674,7 +669,8 @@ export class VideoContainer extends LargeContainer {
}
orientationFit = { this._backgroundOrientation }
videoElement = { this.video }
videoTrack = { this.stream } />
videoTrack = { this.stream } />,
document.getElementById('largeVideoBackgroundContainer')
);
}
}

View File

@@ -29,4 +29,3 @@ export function isIpadMobileBrowser() {
// @ts-ignore
return isIosMobileBrowser() && Platform.isPad;
}

View File

@@ -0,0 +1 @@
export * from './utils.any';

View File

@@ -0,0 +1,37 @@
import { MIN_FILMSTRIP_RESIZE_WIDTH } from '../../filmstrip/constants';
/**
* Detects if the current device has touch capability.
* This includes smartphones, tablets, and laptops with touch screens.
*
* @returns {boolean} True if the device supports touch events.
*/
export function isTouchDevice(): boolean {
// Check maxTouchPoints (most reliable for modern browsers)
if ('maxTouchPoints' in navigator) {
return navigator.maxTouchPoints > 0;
}
return false;
}
/**
* Determines if resize functionality should be enabled based on device capabilities
* and screen size. On touch devices, resize is only enabled for larger screens.
* On non-touch devices (desktop), resize is always enabled.
*
* @returns {boolean} True if resize functionality should be available to the user.
*/
export function shouldEnableResize(): boolean {
const hasTouch = isTouchDevice();
// On non-touch devices (desktop), always enable resize
if (!hasTouch) {
return true;
}
// On touch devices, only enable if screen is large enough.
return window?.innerWidth >= MIN_FILMSTRIP_RESIZE_WIDTH;
}
export * from './utils.any';

View File

@@ -60,66 +60,6 @@ const AVATAR_CHECKER_FUNCTIONS = [
return null;
}
];
/* eslint-enable arrow-body-style */
/**
* Returns the list of active speakers that should be moved to the top of the sorted list of participants so that the
* dominant speaker is visible always on the vertical filmstrip in stage layout.
*
* @param {Function | Object} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {Array<string>}
*/
export function getActiveSpeakersToBeDisplayed(stateful: IStateful) {
const state = toState(stateful);
const {
dominantSpeaker,
fakeParticipants,
sortedRemoteVirtualScreenshareParticipants,
speakersList
} = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
let activeSpeakers = new Map(speakersList);
// Do not re-sort the active speakers if dominant speaker is currently visible.
if (dominantSpeaker && visibleRemoteParticipants.has(dominantSpeaker)) {
return activeSpeakers;
}
let availableSlotsForActiveSpeakers = visibleRemoteParticipants.size;
if (activeSpeakers.has(dominantSpeaker ?? '')) {
activeSpeakers.delete(dominantSpeaker ?? '');
}
// Add dominant speaker to the beginning of the list (not including self) since the active speaker list is always
// alphabetically sorted.
if (dominantSpeaker && dominantSpeaker !== getLocalParticipant(state)?.id) {
const updatedSpeakers = Array.from(activeSpeakers);
updatedSpeakers.splice(0, 0, [ dominantSpeaker, getParticipantById(state, dominantSpeaker)?.name ?? '' ]);
activeSpeakers = new Map(updatedSpeakers);
}
// Remove screenshares from the count.
if (sortedRemoteVirtualScreenshareParticipants) {
availableSlotsForActiveSpeakers -= sortedRemoteVirtualScreenshareParticipants.size * 2;
for (const screenshare of Array.from(sortedRemoteVirtualScreenshareParticipants.keys())) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare as string);
activeSpeakers.delete(ownerId);
}
}
// Remove fake participants from the count.
if (fakeParticipants) {
availableSlotsForActiveSpeakers -= fakeParticipants.size;
}
const truncatedSpeakersList = Array.from(activeSpeakers).slice(0, availableSlotsForActiveSpeakers);
truncatedSpeakersList.sort((a: any, b: any) => a[1].localeCompare(b[1]));
return new Map(truncatedSpeakersList);
}
/**
* Resolves the first loadable avatar URL for a participant.

View File

@@ -68,6 +68,7 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
];
const DEFAULT_STATE = {
activeSpeakers: new Set<string>(),
dominantSpeaker: undefined,
fakeParticipants: new Map(),
local: undefined,
@@ -82,10 +83,10 @@ const DEFAULT_STATE = {
remoteVideoSources: new Set<string>(),
sortedRemoteVirtualScreenshareParticipants: new Map(),
sortedRemoteParticipants: new Map(),
speakersList: new Map()
};
export interface IParticipantsState {
activeSpeakers: Set<string>;
dominantSpeaker?: string;
fakeParticipants: Map<string, IParticipant>;
local?: ILocalParticipant;
@@ -100,7 +101,6 @@ export interface IParticipantsState {
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}
/**
@@ -157,22 +157,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
const { participant } = action;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const newSpeakers = [ id, ...previousSpeakers ];
const sortedSpeakersList: Array<Array<string>> = [];
for (const speaker of newSpeakers) {
if (speaker !== local?.id) {
const remoteParticipant = state.remote.get(speaker);
remoteParticipant
&& sortedSpeakersList.push(
[ speaker, _getDisplayName(state, remoteParticipant?.name) ]
);
}
}
// Keep the remote speaker list sorted alphabetically.
sortedSpeakersList.sort((a, b) => a[1].localeCompare(b[1]));
const activeSpeakers = new Set(previousSpeakers.filter((speakerId: string) => speakerId !== local?.id));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
@@ -183,7 +168,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
return {
...state,
dominantSpeaker: id, // @ts-ignore
speakersList: new Map(sortedSpeakersList)
activeSpeakers
};
}
@@ -438,7 +423,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
}
// Remove the participant from the list of speakers.
state.speakersList.has(id) && state.speakersList.delete(id);
state.activeSpeakers.delete(id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;

View File

@@ -155,6 +155,7 @@ export const colorMap = {
preMeetingBackground: 'surface02', // Pre-meeting screen container background
preMeetingPreview: 'ui01', // Video preview in pre-meeting
prejoinDialogBackground: 'uiBackground', // Prejoin dialog background
prejoinPreviewBackground: 'uiBackground', // Prejoin video preview background (#040404)
prejoinDialogDelimiter: 'ui03', // Prejoin dialog delimiter line
prejoinDialogDelimiterText: 'text01', // Prejoin dialog delimiter text
prejoinTitleText: 'text01', // Prejoin title text color
@@ -220,9 +221,9 @@ export const colorMap = {
// Welcome Page
welcomeBackground: 'surface01', // Welcome page background (same as uiBackground)
welcomeCard: 'ui01', // Welcome page tab bar background
welcomeCard: 'surface02', // Welcome page tab bar background
welcomeTabActive: 'icon01', // Welcome page active tab icon
welcomeTabInactive: 'icon03', // Welcome page inactive tab icon
welcomeTabInactive: 'ui03', // Welcome page inactive tab icon
// ----- Form Components -----
@@ -356,6 +357,9 @@ export const colorMap = {
// Reactions
reactionsMenuBackground: 'ui01', // Reactions menu background
reactionsMenuBorder: 'ui02', // Reactions menu border
reactionsMenuButtonToggled: 'surface01', // Reactions menu button toggled state background
reactionsMenuBoxShadow1: 'ui09', // Reactions menu box shadow primary
reactionsMenuBoxShadow2: 'ui08', // Reactions menu box shadow secondary
// Recording / Live Stream
recordingBackground: 'ui01', // Recording panel background
@@ -428,7 +432,7 @@ export const colorMap = {
securityDialogBorder: 'ui07', // Security dialog border color
// Deep Linking
deepLinkingBackground: 'ui01', // Deep linking page content pane background
deepLinkingBackground: 'uiBackground', // Deep linking page content pane background (#1e1e1e)
deepLinkingBorder: 'ui03', // Deep linking page content pane border
deepLinkingText: 'text01', // Deep linking page text
deepLinkingSeparator: 'ui03', // Deep linking separator line
@@ -505,6 +509,9 @@ export const colorMap = {
// High-contrast
icon04: 'surface01',
// SVG fill color
iconSvgFill: 'icon01',
// Error
iconError: 'action03',

View File

@@ -81,7 +81,11 @@ const useStyles = makeStyles()(theme => {
'&:focus': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.inputFieldFocus}`
boxShadow: `0px 0px 0px 2px ${theme.palette.inputFieldFocus}`,
'&::placeholder': {
opacity: 0
}
},
'&:disabled': {
@@ -107,12 +111,12 @@ const useStyles = makeStyles()(theme => {
},
'input::-webkit-outer-spin-button, input::-webkit-inner-spin-button': {
WebkitAppearance: 'none',
'-webkit-appearance': 'none',
margin: 0
},
'input[type=number]': {
MozAppearance: 'textfield'
'-moz-appearance': 'textfield'
},
icon: {

View File

@@ -13,19 +13,25 @@ export const commonStyles = (theme: Theme) => {
return {
':root': {
// Inject semantic tokens as CSS custom properties for use in SCSS
'--toolbox-background-color': theme.palette.toolboxBackground,
'--drawer-background-color': theme.palette.drawerBackground,
'--icon-svg-fill': theme.palette.iconSvgFill,
'--overflow-menu-background-color': theme.palette.overflowMenuBackground,
'--overflow-menu-item-disabled-color': theme.palette.overflowMenuItemDisabled,
'--overflow-menu-item-hover-color': theme.palette.overflowMenuItemHover,
'--overflow-menu-item-icon-color': theme.palette.overflowMenuItemIcon,
'--overflow-menu-item-text-color': theme.palette.overflowMenuItemText,
'--prejoin-preview-background': theme.palette.prejoinPreviewBackground,
'--reactions-menu-background': theme.palette.reactionsMenuBackground,
'--reactions-menu-box-shadow-1': theme.palette.reactionsMenuBoxShadow1,
'--reactions-menu-box-shadow-2': theme.palette.reactionsMenuBoxShadow2,
'--reactions-menu-button-toggled': theme.palette.reactionsMenuButtonToggled,
'--toolbar-button-active-color': theme.palette.toolbarButtonActive,
'--toolbar-button-color': theme.palette.toolbarButton,
'--toolbar-button-hover-color': theme.palette.toolbarButtonHover,
'--toolbar-button-active-color': theme.palette.toolbarButtonActive,
'--toolbar-icon-active-color': theme.palette.toolbarIconActive,
'--toolbar-icon-color': theme.palette.toolbarIcon,
'--toolbar-icon-hover-color': theme.palette.toolbarIconHover,
'--toolbar-icon-active-color': theme.palette.toolbarIconActive,
'--overflow-menu-background-color': theme.palette.overflowMenuBackground,
'--overflow-menu-item-text-color': theme.palette.overflowMenuItemText,
'--overflow-menu-item-icon-color': theme.palette.overflowMenuItemIcon,
'--overflow-menu-item-hover-color': theme.palette.overflowMenuItemHover,
'--overflow-menu-item-disabled-color': theme.palette.overflowMenuItemDisabled
'--toolbox-background-color': theme.palette.toolboxBackground
},
'.empty-list': {

View File

@@ -26,6 +26,7 @@ export interface IPalette {
icon02: string;
icon03: string;
icon04: string;
iconSvgFill: string;
iconError: string;
link01: string;
link01Active: string;
@@ -153,6 +154,7 @@ export interface IPalette {
prejoinDialogBackground: string;
prejoinDialogDelimiter: string;
prejoinDialogDelimiterText: string;
prejoinPreviewBackground: string;
prejoinRecordingWarningText: string;
prejoinRoomNameText: string;
prejoinTitleText: string;
@@ -313,6 +315,9 @@ export interface IPalette {
pollsVotersText: string;
reactionsMenuBackground: string;
reactionsMenuBorder: string;
reactionsMenuButtonToggled: string;
reactionsMenuBoxShadow1: string;
reactionsMenuBoxShadow2: string;
recordingBackground: string;
recordingHighlightButton: string;
recordingHighlightButtonDisabled: string;

View File

@@ -24,3 +24,13 @@ const JITSI_MEET_APPS = [
export function isEmbedded(): boolean {
return !JITSI_MEET_APPS.includes(getBundleId());
}
/**
* React Native has no concept of same-domain embedding. SDK consumers are
* always treated as cross-domain embeddings.
*
* @returns {boolean} Always false in React Native.
*/
export function isEmbeddedFromSameDomain(): boolean {
return false;
}

View File

@@ -10,3 +10,17 @@ export function isEmbedded(): boolean {
return true;
}
}
/**
* Checks whether we are loaded in iframe with same parent domain.
*
* @returns {boolean} Whether the current page is loaded in an iframe with same parent domain.
*/
export function isEmbeddedFromSameDomain(): boolean {
try {
return window.self.location.host === window.parent.location.host;
} catch (e) {
return false;
}
}

View File

@@ -4,6 +4,7 @@ import { connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { IconInfo, IconMessage, IconShareDoc, IconSubtitles } from '../../../base/icons/svg';
import { getLocalParticipant, getRemoteParticipants, isPrivateChatEnabledSelf } from '../../../base/participants/functions';
@@ -23,7 +24,16 @@ import {
setUserChatWidth,
toggleChat
} from '../../actions.web';
import { CHAT_SIZE, ChatTabs, OPTION_GROUPCHAT, SMALL_WIDTH_THRESHOLD } from '../../constants';
import {
CHAT_DRAG_HANDLE_HEIGHT,
CHAT_DRAG_HANDLE_OFFSET,
CHAT_DRAG_HANDLE_WIDTH,
CHAT_SIZE,
CHAT_TOUCH_HANDLE_SIZE,
ChatTabs,
OPTION_GROUPCHAT,
SMALL_WIDTH_THRESHOLD
} from '../../constants';
import { getChatMaxSize, getFocusedTab, isChatDisabled } from '../../functions';
import { IChatProps as AbstractProps } from '../../types';
@@ -104,7 +114,12 @@ interface IProps extends AbstractProps {
_width: number;
}
const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme, { _isResizing, width }) => {
const useStyles = makeStyles<{
_isResizing: boolean;
isTouch: boolean;
resizeEnabled: boolean;
width: number;
}>()((theme, { _isResizing, isTouch, resizeEnabled, width }) => {
return {
container: {
backgroundColor: theme.palette.chatBackground,
@@ -115,11 +130,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
width: `${width}px`,
zIndex: 300,
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
// On non-touch devices (desktop), show handle on hover
// On touch devices, handle is always visible if resize is enabled
...(!isTouch && {
'&:hover, &:focus-within': {
'& .dragHandleContainer': {
visibility: 'visible'
}
}
},
}),
'@media (max-width: 580px)': {
height: '100dvh',
@@ -132,7 +151,7 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
'*': {
userSelect: 'text',
WebkitUserSelect: 'text'
'-webkit-user-select': 'text'
}
},
@@ -183,16 +202,23 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
dragHandleContainer: {
height: '100%',
width: '9px',
// Touch devices need larger hit target but positioned to not take extra space
width: isTouch ? `${CHAT_TOUCH_HANDLE_SIZE}px` : `${CHAT_DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: 'flex',
display: resizeEnabled ? 'flex' : 'none', // Hide if resize not enabled
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden',
right: '4px',
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden',
// Position touch handle centered on offset from edge, maintaining same gap as non-touch
right: isTouch
? `${CHAT_DRAG_HANDLE_OFFSET - Math.floor((CHAT_TOUCH_HANDLE_SIZE - CHAT_DRAG_HANDLE_WIDTH) / 2)}px`
: `${CHAT_DRAG_HANDLE_OFFSET}px`,
top: 0,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
@@ -210,10 +236,15 @@ const useStyles = makeStyles<{ _isResizing: boolean; width: number; }>()((theme,
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.icon02,
height: '100px',
width: '3px',
borderRadius: '1px'
height: `${CHAT_DRAG_HANDLE_HEIGHT}px`,
width: `${CHAT_DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px',
// Make more visible when actively shown
...(isTouch && resizeEnabled && {
backgroundColor: theme.palette.icon01
})
},
privateMessageRecipientsList: {
@@ -246,7 +277,10 @@ const Chat = ({
return null;
}
const { classes, cx } = useStyles({ _isResizing, width: _width });
// Detect touch capability and screen size for resize functionality
const isTouch = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const { classes, cx } = useStyles({ _isResizing, width: _width, isTouch, resizeEnabled });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragChatWidth, setDragChatWidth ] = useState<number | null>(null);
@@ -282,16 +316,21 @@ const Chat = ({
}, [ participants, defaultRemoteDisplayName, t, notifyTimestamp ]);
/**
* Handles mouse down on the drag handle.
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mouse down event.
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Store the initial mouse position and chat width
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Store the initial pointer position and chat width
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragChatWidth(_width);
@@ -299,7 +338,7 @@ const Chat = ({
// Indicate that resizing is in progress
dispatch(setChatIsResizing(true));
// Add visual feedback that we're dragging
// Add visual feedback that we're dragging (cursor for mouse, not visible on touch)
document.body.style.cursor = 'col-resize';
// Disable text selection during resize
@@ -307,11 +346,12 @@ const Chat = ({
}, [ _width, dispatch ]);
/**
* Drag handle mouse up handler.
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
const onDragMouseUp = useCallback(() => {
const onDragPointerUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setChatIsResizing(false));
@@ -323,12 +363,13 @@ const Chat = ({
}, [ isMouseDown, dispatch ]);
/**
* Handles drag handle mouse move.
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mousemove event.
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
const onChatResize = useCallback(throttle((e: MouseEvent) => {
const onChatResize = useCallback(throttle((e: PointerEvent) => {
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
// For chat panel resizing on the left edge:
// - Dragging left (decreasing X coordinate) should make the panel wider
@@ -352,14 +393,14 @@ const Chat = ({
// Set up event listeners when component mounts
useEffect(() => {
document.addEventListener('mouseup', onDragMouseUp);
document.addEventListener('mousemove', onChatResize);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointermove', onChatResize);
return () => {
document.removeEventListener('mouseup', onDragMouseUp);
document.removeEventListener('mousemove', onChatResize);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointermove', onChatResize);
};
}, [ onDragMouseUp, onChatResize ]);
}, [ onDragPointerUp, onChatResize ]);
/**
* Sends a text message.
@@ -600,7 +641,7 @@ const Chat = ({
(isMouseDown || _isResizing) && 'visible',
'dragHandleContainer'
) }
onMouseDown = { onDragHandleMouseDown }>
onPointerDown = { onDragHandlePointerDown }>
<div className = { cx(classes.dragHandle, 'dragHandle') } />
</div>
</div> : null

View File

@@ -33,6 +33,23 @@ export const MESSAGE_TYPE_REMOTE = 'remote';
export const SMALL_WIDTH_THRESHOLD = 580;
/**
* Drag handle dimensions for resizable chat.
*/
export const CHAT_DRAG_HANDLE_WIDTH = 9;
export const CHAT_DRAG_HANDLE_HEIGHT = 100;
/**
* Touch target size for chat drag handle on touch devices.
* Provides adequate hit area (44px) for comfortable tapping.
*/
export const CHAT_TOUCH_HANDLE_SIZE = 44;
/**
* Offset from edge for positioning the chat drag handle.
*/
export const CHAT_DRAG_HANDLE_OFFSET = 4;
/**
* Lobby message type.

View File

@@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconCloudUpload } from '../../../base/icons/svg';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
import Button from '../../../base/ui/components/web/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
@@ -226,16 +227,20 @@ const FileSharing = () => {
)
}
{
isUploadEnabled && (
<Tooltip
containerClassName = { classes.uploadButton }
content = { isUploadEnabled ? t('fileSharing.uploadFile') : t('fileSharing.uploadDisabled') }
position = 'top'>
<Button
accessibilityLabel = { t('fileSharing.uploadFile') }
className = { classes.uploadButton }
disabled = { !isUploadEnabled }
labelKey = { 'fileSharing.uploadFile' }
onClick = { handleClick }
onKeyPress = { handleKeyPress }
ref = { uploadButtonRef }
type = { BUTTON_TYPES.PRIMARY } />
)
</Tooltip>
}
</div>
);

View File

@@ -72,6 +72,9 @@ StateListenerRegistry.register(
type: _FILE_REMOVED,
fileId
});
// Notify external API about file deletion (for remote participants)
APP.API.notifyFileDeleted(fileId);
});
conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => {
@@ -83,6 +86,11 @@ StateListenerRegistry.register(
files,
localParticipantId: localParticipant?.id
});
// Notify external API about existing files (for participants joining late)
Object.values(files).forEach((file: IFileMetadata) => {
APP.API.notifyFileUploaded(file);
});
});
}
});
@@ -152,6 +160,8 @@ MiddlewareRegistry.register(store => next => action => {
privateMessage: false,
timestamp: file.timestamp
}));
APP.API.notifyFileUploaded(file);
}
return result;
@@ -178,6 +188,9 @@ MiddlewareRegistry.register(store => next => action => {
fileId
});
// Notify external API about file deletion (for the local participant who deleted it)
APP.API.notifyFileDeleted(fileId);
const { fileSharing } = state['features/base/config'];
const sessionId = conference.getMeetingUniqueId();

View File

@@ -60,16 +60,19 @@ export function setRemoteParticipants(participants: Array<string>) {
*
* @param {number} startIndex - The start index from the remote participants array.
* @param {number} endIndex - The end index from the remote participants array.
* @param {number} fullyVisibleCount - The number of fully visible participants (excluding partially visible).
* @returns {{
* type: SET_VISIBLE_REMOTE_PARTICIPANTS,
* startIndex: number,
* endIndex: number
* endIndex: number,
* fullyVisibleCount: number
* }}
*/
export function setVisibleRemoteParticipants(startIndex: number, endIndex: number) {
export function setVisibleRemoteParticipants(startIndex: number, endIndex: number, fullyVisibleCount?: number) {
return {
type: SET_VISIBLE_REMOTE_PARTICIPANTS,
startIndex,
endIndex
endIndex,
fullyVisibleCount
};
}

View File

@@ -10,6 +10,7 @@ import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import { setVisibleRemoteParticipants } from '../../actions.native';
import { calculateFullyVisibleParticipantsCount } from '../../functions.any';
import {
getFilmstripDimensions,
isFilmstripVisible,
@@ -190,7 +191,7 @@ class Filmstrip extends PureComponent<IProps> {
* @returns {void}
*/
_onViewableItemsChanged({ viewableItems = [] }: { viewableItems: ViewToken[]; }) {
const { _disableSelfView } = this.props;
const { _aspectRatio, _clientWidth, _clientHeight, _disableSelfView } = this.props;
if (!this._separateLocalThumbnail && !_disableSelfView && viewableItems[0]?.index === 0) {
// Skip the local thumbnail.
@@ -205,13 +206,31 @@ class Filmstrip extends PureComponent<IProps> {
let startIndex = Number(viewableItems[0].index);
let endIndex = Number(viewableItems[viewableItems.length - 1].index);
// Calculate fully visible count (excluding partially visible tiles)
const isNarrowAspectRatio = _aspectRatio === ASPECT_RATIO_NARROW;
const { height: thumbnailHeight, width: thumbnailWidth, margin } = styles.thumbnail;
const { height, width } = this._getDimensions();
// Calculate item size and container size based on layout orientation
const itemSize = isNarrowAspectRatio
? thumbnailWidth + (2 * margin) // Horizontal layout
: thumbnailHeight + (2 * margin); // Vertical layout
const containerSize = isNarrowAspectRatio ? width : height;
const fullyVisibleCount = calculateFullyVisibleParticipantsCount(
startIndex,
endIndex,
containerSize,
itemSize
);
if (!this._separateLocalThumbnail && !_disableSelfView) {
// We are off by one in the remote participants array.
startIndex -= 1;
endIndex -= 1;
}
this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex));
this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex, fullyVisibleCount));
}
/**

View File

@@ -10,7 +10,7 @@ import { withStyles } from 'tss-react/mui';
import { ACTION_SHORTCUT_TRIGGERED, createShortcutEvent, createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { isMobileBrowser, isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowDown, IconArrowUp } from '../../../base/icons/svg';
@@ -32,13 +32,19 @@ import {
import {
ASPECT_RATIO_BREAKPOINT,
DEFAULT_FILMSTRIP_WIDTH,
DRAG_HANDLE_HEIGHT,
DRAG_HANDLE_TOP_PANEL_HEIGHT,
DRAG_HANDLE_TOP_PANEL_WIDTH,
DRAG_HANDLE_WIDTH,
FILMSTRIP_TYPE,
MIN_STAGE_VIEW_HEIGHT,
MIN_STAGE_VIEW_WIDTH,
TILE_HORIZONTAL_MARGIN,
TILE_VERTICAL_MARGIN,
TOP_FILMSTRIP_HEIGHT
TOP_FILMSTRIP_HEIGHT,
TOUCH_DRAG_HANDLE_PADDING
} from '../../constants';
import { calculateFullyVisibleParticipantsCount } from '../../functions.any';
import {
getVerticalViewMaxWidth,
isFilmstripDisabled,
@@ -52,6 +58,21 @@ import ThumbnailWrapper from './ThumbnailWrapper';
const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
const TOUCH_DEVICE_PADDING = {
paddingLeft: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingRight: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingTop: 0,
paddingBottom: 0
};
const TOUCH_DEVICE_TOP_PANEL_PADDING = {
paddingLeft: 0,
paddingRight: 0,
paddingTop: `${TOUCH_DRAG_HANDLE_PADDING}px`,
paddingBottom: `${TOUCH_DRAG_HANDLE_PADDING}px`
};
const NON_TOUCH_DEVICE_PANEL = {
pading: 0
};
/**
* Creates the styles for the component.
@@ -61,6 +82,14 @@ const BACKGROUND_COLOR = 'rgba(51, 51, 51, .5)';
* @returns {Object}
*/
function styles(theme: Theme, props: IProps) {
const { _topPanelFilmstrip: isTopPanel } = props;
const _isTouchDevice = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const handlePaddding = _isTouchDevice
? (isTopPanel ? TOUCH_DEVICE_TOP_PANEL_PADDING : TOUCH_DEVICE_PADDING)
: NON_TOUCH_DEVICE_PANEL;
const result = {
toggleFilmstripContainer: {
display: 'flex',
@@ -94,7 +123,7 @@ function styles(theme: Theme, props: IProps) {
margin: 0,
border: 'none',
WebkitAppearance: 'none' as const,
'-webkit-appearance': 'none',
'& svg': {
fill: theme.palette.icon01
@@ -122,23 +151,27 @@ function styles(theme: Theme, props: IProps) {
right: 0,
bottom: 0,
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
// On touch devices, handle is always visible via base styles, so no hover needed.
// On desktop, show handle on hover/focus.
...(!_isTouchDevice && {
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .filmstrip-hover': {
backgroundColor: BACKGROUND_COLOR
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .toggleFilmstripContainer': {
opacity: 1
},
'& .dragHandleContainer': {
visibility: 'visible' as const
'& .dragHandleContainer': {
visibility: 'visible' as const
}
}
},
}),
'.horizontal-filmstrip &.hidden': {
bottom: '-50px',
@@ -187,14 +220,22 @@ function styles(theme: Theme, props: IProps) {
dragHandleContainer: {
height: '100%',
width: '9px',
width: `${DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'relative' as const,
cursor: 'col-resize',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
visibility: 'hidden' as const,
// On touch devices, always visible if resize enabled. On desktop, hidden by default
visibility: (_isTouchDevice && resizeEnabled) ? 'visible' as const : 'hidden' as const,
marginLeft: 0,
marginTop: 0,
// Touch devices get padding for easier tapping
// Vertical filmstrip: left/right padding. Top panel: top/bottom padding.
...handlePaddding,
// Prevent touch scrolling while dragging
touchAction: 'none',
'&:hover': {
'& .dragHandle': {
@@ -213,20 +254,21 @@ function styles(theme: Theme, props: IProps) {
'&.top-panel': {
order: 2,
width: '100%',
height: '9px',
height: `${DRAG_HANDLE_WIDTH}px`,
cursor: 'row-resize',
'& .dragHandle': {
height: '3px',
width: '100px'
height: `${DRAG_HANDLE_TOP_PANEL_HEIGHT}px`,
width: `${DRAG_HANDLE_TOP_PANEL_WIDTH}px`
}
}
},
dragHandle: {
// Keep the same visual appearance on all devices
backgroundColor: theme.palette.filmstripDragHandle,
height: '100px',
width: '3px',
height: `${DRAG_HANDLE_HEIGHT}px`,
width: `${DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px'
}
};
@@ -313,6 +355,11 @@ export interface IProps extends WithTranslation {
*/
_isToolboxVisible: Boolean;
/**
* Whether the device has touch capability.
*/
_isTouchDevice?: boolean;
/**
* Whether or not the current layout is vertical filmstrip.
*/
@@ -358,6 +405,11 @@ export interface IProps extends WithTranslation {
*/
_resizableFilmstrip: boolean;
/**
* Whether resize functionality should be enabled based on device and screen size.
*/
_resizeEnabled?: boolean;
/**
* The number of rows in tile view.
*/
@@ -491,8 +543,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
this._onGridItemsRendered = this._onGridItemsRendered.bind(this);
this._onListItemsRendered = this._onListItemsRendered.bind(this);
this._onToggleButtonTouch = this._onToggleButtonTouch.bind(this);
this._onDragHandleMouseDown = this._onDragHandleMouseDown.bind(this);
this._onDragMouseUp = this._onDragMouseUp.bind(this);
this._onDragHandlePointerDown = this._onDragHandlePointerDown.bind(this);
this._onDragHandleClick = this._onDragHandleClick.bind(this);
this._onDragHandleTouchStart = this._onDragHandleTouchStart.bind(this);
this._onDragPointerUp = this._onDragPointerUp.bind(this);
this._onFilmstripResize = this._onFilmstripResize.bind(this);
this._throttledResize = throttle(
@@ -516,10 +570,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
handler: this._onShortcutToggleFilmstrip
}));
document.addEventListener('mouseup', this._onDragMouseUp);
document.addEventListener('pointerup', this._onDragPointerUp);
// @ts-ignore
document.addEventListener('mousemove', this._throttledResize);
document.addEventListener('pointermove', this._throttledResize);
}
/**
@@ -530,10 +584,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
override componentWillUnmount() {
this.props.dispatch(unregisterShortcut('F'));
document.removeEventListener('mouseup', this._onDragMouseUp);
document.removeEventListener('pointerup', this._onDragPointerUp);
// @ts-ignore
document.removeEventListener('mousemove', this._throttledResize);
document.removeEventListener('pointermove', this._throttledResize);
}
/**
@@ -678,7 +732,9 @@ class Filmstrip extends PureComponent <IProps, IState> {
(isMouseDown || _alwaysShowResizeBar) && 'visible',
_topPanelFilmstrip && 'top-panel')
}
onMouseDown = { this._onDragHandleMouseDown }>
onClick = { this._onDragHandleClick }
onPointerDown = { this._onDragHandlePointerDown }
onTouchStart = { this._onDragHandleTouchStart }>
<div className = { clsx(classes.dragHandle, 'dragHandle') } />
</div>
{filmstrip}
@@ -691,14 +747,23 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Handles mouse down on the drag handle.
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mouse down event.
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
_onDragHandleMouseDown(e: React.MouseEvent) {
_onDragHandlePointerDown(e: React.PointerEvent) {
const { _topPanelFilmstrip, _topPanelHeight, _verticalFilmstripWidth } = this.props;
// Prevent toolbar from appearing and stop event propagation
e.preventDefault();
e.stopPropagation();
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element
(e.target as HTMLElement).setPointerCapture(e.pointerId);
this.setState({
isMouseDown: true,
mousePosition: _topPanelFilmstrip ? e.clientY : e.clientX,
@@ -709,11 +774,33 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Drag handle mouse up handler.
* Prevents click events on drag handle from triggering toolbar.
*
* @param {React.MouseEvent} e - The click event.
* @returns {void}
*/
_onDragHandleClick(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
/**
* Prevents touch start events on drag handle from triggering toolbar.
*
* @param {React.TouchEvent} e - The touch start event.
* @returns {void}
*/
_onDragHandleTouchStart(e: React.TouchEvent) {
e.stopPropagation();
}
/**
* Drag handle pointer up handler.
* Supports both mouse and touch events via Pointer Events API.
*
* @returns {void}
*/
_onDragMouseUp() {
_onDragPointerUp() {
if (this.state.isMouseDown) {
this.setState({
isMouseDown: false
@@ -723,12 +810,13 @@ class Filmstrip extends PureComponent <IProps, IState> {
}
/**
* Handles drag handle mouse move.
* Handles drag handle pointer move.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {MouseEvent} e - The mousemove event.
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
_onFilmstripResize(e: React.MouseEvent) {
_onFilmstripResize(e: PointerEvent) {
if (this.state.isMouseDown) {
const {
dispatch,
@@ -857,10 +945,33 @@ class Filmstrip extends PureComponent <IProps, IState> {
*/
_onListItemsRendered({ visibleStartIndex, visibleStopIndex }: {
visibleStartIndex: number; visibleStopIndex: number; }) {
const { dispatch } = this.props;
const {
dispatch,
_currentLayout,
_filmstripWidth,
_filmstripHeight,
_thumbnailWidth,
_thumbnailHeight,
_isVerticalFilmstrip
} = this.props;
// Calculate fully visible count (excluding partially visible tiles)
const isHorizontal = _currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW;
const itemSize = isHorizontal
? _thumbnailWidth + TILE_HORIZONTAL_MARGIN
: _thumbnailHeight + TILE_VERTICAL_MARGIN;
const containerSize = isHorizontal ? _filmstripWidth : _filmstripHeight;
const fullyVisibleCount = calculateFullyVisibleParticipantsCount(
visibleStartIndex,
visibleStopIndex,
containerSize,
itemSize
);
const { startIndex, stopIndex } = this._calculateIndices(visibleStartIndex, visibleStopIndex);
dispatch(setVisibleRemoteParticipants(startIndex, stopIndex));
dispatch(setVisibleRemoteParticipants(startIndex, stopIndex, fullyVisibleCount));
}
/**
@@ -1163,4 +1274,4 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
};
}
export default withStyles(translate(connect(_mapStateToProps)(Filmstrip)), styles);
export default translate(connect(_mapStateToProps)(withStyles(Filmstrip, styles)));

View File

@@ -353,7 +353,7 @@ const defaultStyles = (theme: Theme) => {
tintBackground: {
position: 'absolute' as const,
zIndex: 1,
zIndex: 0,
width: '100%',
height: '100%',
backgroundColor: theme.palette.thumbnailTintBackground,

View File

@@ -269,6 +269,19 @@ export const MIN_STAGE_VIEW_HEIGHT = 700;
*/
export const MIN_STAGE_VIEW_WIDTH = 800;
/**
* Drag handle dimensions for resizable filmstrip.
*/
export const DRAG_HANDLE_WIDTH = 9;
export const DRAG_HANDLE_HEIGHT = 100;
export const DRAG_HANDLE_TOP_PANEL_HEIGHT = 3;
export const DRAG_HANDLE_TOP_PANEL_WIDTH = 100;
/**
* Touch padding added to each side of drag handle for easier tapping on touch devices.
*/
export const TOUCH_DRAG_HANDLE_PADDING = 6;
/**
* Horizontal margin used for the vertical filmstrip.
*/
@@ -298,3 +311,9 @@ export const MAX_ACTIVE_PARTICIPANTS = 6;
* Top filmstrip default height.
*/
export const TOP_FILMSTRIP_HEIGHT = 180;
/**
* Minimum screen width needed for functional filmstrip resizing.
* Calculated as stage minimum + filmstrip minimum (800px + 120px = 920px).
*/
export const MIN_FILMSTRIP_RESIZE_WIDTH = MIN_STAGE_VIEW_WIDTH + DEFAULT_FILMSTRIP_WIDTH;

View File

@@ -1,8 +1,5 @@
import { IReduxState, IStore } from '../app/types';
import {
getActiveSpeakersToBeDisplayed,
getVirtualScreenshareParticipantOwnerId
} from '../base/participants/functions';
import { getParticipantById, getVirtualScreenshareParticipantOwnerId } from '../base/participants/functions';
import { setRemoteParticipants } from './actions';
import { isFilmstripScrollVisible } from './functions';
@@ -33,44 +30,70 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
}
const {
activeSpeakers,
dominantSpeaker,
fakeParticipants,
sortedRemoteParticipants
} = state['features/base/participants'];
const config = state['features/base/config'];
const defaultRemoteDisplayName = config?.defaultRemoteDisplayName ?? 'Fellow Jitster';
const dominant = dominantSpeaker ? getParticipantById(state, dominantSpeaker) : undefined;
let dominantSpeakerSlot = 0;
const previousSpeakers = new Set(activeSpeakers);
const remoteParticipants = new Map(sortedRemoteParticipants);
const screenShareParticipants = sortedRemoteVirtualScreenshareParticipants
? [ ...sortedRemoteVirtualScreenshareParticipants.keys() ] : [];
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
const speakers = getActiveSpeakersToBeDisplayed(state);
const speakers = new Array<string>();
const { fullyVisibleRemoteParticipantsCount } = state['features/filmstrip'];
for (const screenshare of screenShareParticipants) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
speakers.delete(ownerId);
}
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
}
for (const speaker of speakers.keys()) {
remoteParticipants.delete(speaker);
}
// Always update the order of the thubmnails.
const participantsWithScreenShare = screenShareParticipants.reduce<string[]>((acc, screenshare) => {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
acc.push(ownerId);
acc.push(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
previousSpeakers.delete(ownerId);
return acc;
}, []);
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
}
if (dominant && !dominant.local && participantsWithScreenShare.indexOf(dominant.id) === -1) {
dominantSpeakerSlot = 1;
remoteParticipants.delete(dominant.id);
speakers.push(dominant.id);
}
// Find the number of slots available for speakers. Use fullyVisibleRemoteParticipantsCount to exclude partially
// visible tiles, ensuring dominant speaker is placed on a fully visible tile.
const slotsForSpeakers
= fullyVisibleRemoteParticipantsCount
- (screenShareParticipants.length * 2)
- sharedVideos.length
- dominantSpeakerSlot;
// Construct the list of speakers to be shown.
if (slotsForSpeakers > 0) {
Array.from(previousSpeakers).slice(0, slotsForSpeakers).forEach((speakerId: string) => {
speakers.push(speakerId);
remoteParticipants.delete(speakerId);
});
speakers.sort((a: string, b: string) => {
return (getParticipantById(state, a)?.name ?? defaultRemoteDisplayName)
.localeCompare(getParticipantById(state, b)?.name ?? defaultRemoteDisplayName);
});
}
// Always update the order of the thumbnails.
reorderedParticipants = [
...participantsWithScreenShare,
...sharedVideos,
...Array.from(speakers.keys()),
...speakers,
...Array.from(remoteParticipants.keys())
];
@@ -108,3 +131,31 @@ export function isTileViewModeDisabled(state: IReduxState) {
return tileView.disabled;
}
/**
* Calculates the count of fully visible participants, excluding any partially visible tiles.
* This respects the actual rendered items from the list component while accounting for
* container padding/gaps.
*
* @param {number} visibleStartIndex - The start index of visible items.
* @param {number} visibleEndIndex - The end index of visible items.
* @param {number} containerSize - The width or height of the filmstrip container.
* @param {number} itemSize - The width or height of each item including margin.
* @returns {number} - The count of fully visible participants (at least 1).
*/
export function calculateFullyVisibleParticipantsCount(
visibleStartIndex: number,
visibleEndIndex: number,
containerSize: number,
itemSize: number
): number {
// Current visible count from the list component (includes any partially visible tile)
const currentVisibleCount = visibleEndIndex - visibleStartIndex + 1;
// Theoretical max that can fit fully in the container
const maxFullyVisible = Math.floor(containerSize / itemSize);
// Fully visible count is the minimum of actual visible and max that can fit fully
// Ensure at least 1 if there are any visible items
return Math.max(1, Math.min(currentVisibleCount, maxFullyVisible));
}

View File

@@ -2,7 +2,7 @@ import { Theme } from '@mui/material/styles';
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { isMobileBrowser } from '../base/environment/utils';
import { isTouchDevice, shouldEnableResize } from '../base/environment/utils';
import { MEDIA_TYPE } from '../base/media/constants';
import {
getLocalParticipant,
@@ -30,6 +30,7 @@ import {
DEFAULT_LOCAL_TILE_ASPECT_RATIO,
DISPLAY_AVATAR,
DISPLAY_VIDEO,
DRAG_HANDLE_WIDTH,
FILMSTRIP_GRID_BREAKPOINT,
FILMSTRIP_TYPE,
INDICATORS_TOOLTIP_POSITION,
@@ -45,6 +46,7 @@ import {
TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES,
TILE_VIEW_GRID_HORIZONTAL_MARGIN,
TILE_VIEW_GRID_VERTICAL_MARGIN,
TOUCH_DRAG_HANDLE_PADDING,
VERTICAL_VIEW_HORIZONTAL_MARGIN
} from './constants';
@@ -621,6 +623,7 @@ export function getIndicatorsTooltipPosition(thumbnailType?: string) {
/**
* Returns whether or not the filmstrip is resizable.
* On touch devices, resize is only enabled for larger screens (tablets, not phones).
*
* @param {Object} state - Redux state.
* @returns {boolean}
@@ -629,7 +632,7 @@ export function isFilmstripResizable(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
const _currentLayout = getCurrentLayout(state);
return !filmstrip?.disableResizable && !isMobileBrowser()
return !filmstrip?.disableResizable && shouldEnableResize()
&& (_currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW || _currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW);
}
@@ -662,8 +665,13 @@ export function getVerticalViewMaxWidth(state: IReduxState) {
// Adding 4px for the border-right and margin-right.
// On non-resizable filmstrip add 4px for the left margin and border.
// Also adding 7px for the scrollbar. Also adding 9px for the drag handle.
maxWidth += (_verticalViewGrid ? 0 : 11) + (_resizableFilmstrip ? 9 : 4);
// Also adding 7px for the scrollbar.
// Drag handle: DRAG_HANDLE_WIDTH + padding (TOUCH_DRAG_HANDLE_PADDING on each side for touch)
const dragHandleWidth = isTouchDevice()
? DRAG_HANDLE_WIDTH + (TOUCH_DRAG_HANDLE_PADDING * 2)
: DRAG_HANDLE_WIDTH;
maxWidth += (_verticalViewGrid ? 0 : 11) + (_resizableFilmstrip ? dragHandleWidth : 4);
return maxWidth;
}

View File

@@ -158,6 +158,15 @@ const DEFAULT_STATE = {
*/
visibleRemoteParticipants: new Set<string>(),
/**
* The number of fully visible remote participants (excluding partially visible ones).
* Used for calculating speaker slots to avoid placing dominant speaker on partially visible tiles.
*
* @public
* @type {number}
*/
fullyVisibleRemoteParticipantsCount: 0,
/**
* The width of the resizable filmstrip.
*
@@ -201,6 +210,7 @@ export interface IFilmstripState {
pinned?: boolean;
}>;
enabled: boolean;
fullyVisibleRemoteParticipantsCount: number;
horizontalViewDimensions: {
hasScroll?: boolean;
local?: IDimensions;
@@ -301,7 +311,7 @@ ReducerRegistry.register<IFilmstripState>(
}
};
case SET_VISIBLE_REMOTE_PARTICIPANTS: {
const { endIndex, startIndex } = action;
const { endIndex, startIndex, fullyVisibleCount } = action;
const { remoteParticipants } = state;
const visibleRemoteParticipants = new Set(remoteParticipants.slice(startIndex, endIndex + 1));
@@ -309,7 +319,8 @@ ReducerRegistry.register<IFilmstripState>(
...state,
visibleParticipantsStartIndex: startIndex,
visibleParticipantsEndIndex: endIndex,
visibleRemoteParticipants
visibleRemoteParticipants,
fullyVisibleRemoteParticipantsCount: fullyVisibleCount ?? visibleRemoteParticipants.size
};
}
case PARTICIPANT_LEFT: {

View File

@@ -70,10 +70,15 @@ export function getDialInConferenceID(
* phone number strings, as the second one should not be used and is deprecated.
*/
export function getDialInNumbers(
url: string,
roomName: string,
mucURL: string
url?: string,
roomName?: string,
mucURL?: string
): Promise<any> {
if (!url || !roomName || !mucURL) {
return Promise.resolve();
}
const separator = url.includes('?') ? '&' : '?';
// when roomName and mucURL are available

View File

@@ -16,7 +16,8 @@ import {
import { INVITE_TYPES } from './constants';
import {
invitePeopleAndChatRooms,
inviteSipEndpoints
inviteSipEndpoints,
isDialInEnabled
} from './functions';
import logger from './logger';
import { IInvitee } from './types';
@@ -210,7 +211,7 @@ export function updateDialInNumbers() {
const { numbersFetched } = state['features/invite'];
const mucURL = hosts?.muc;
if (numbersFetched || !dialInConfCodeUrl || !dialInNumbersUrl || !mucURL) {
if (numbersFetched || !isDialInEnabled(state)) {
// URLs for fetching dial in numbers not defined
return;
}

View File

@@ -496,6 +496,26 @@ export function isDialOutEnabled(state: IReduxState): boolean {
return isJwtFeatureEnabled(state, MEET_FEATURES.OUTBOUND_CALL, false) && conference?.isSIPCallingSupported();
}
/**
* Determines if dial out is currently enabled or not.
*
* @param {IReduxState} state - Current state.
* @returns {boolean} Indication of whether dial out is currently enabled.
*/
export function isDialInEnabled(state: IReduxState): boolean {
const dialInDisabled = state['features/base/conference']
.conference?.getMetadataHandler()?.getMetadata()?.dialinEnabled === false;
if (dialInDisabled) {
return false;
}
const { dialInConfCodeUrl, dialInNumbersUrl, hosts } = state['features/base/config'];
const mucURL = hosts?.muc;
return Boolean(dialInConfCodeUrl && dialInNumbersUrl && mucURL);
}
/**
* Determines if inviting sip endpoints is enabled or not.
*
@@ -634,7 +654,7 @@ export function getShareInfoText(
const { locationURL = {} } = state['features/base/connection'];
const mucURL = hosts?.muc;
if (skipDialIn || !dialInConfCodeUrl || !dialInNumbersUrl || !mucURL) {
if (skipDialIn || !isDialInEnabled(state)) {
// URLs for fetching dial in numbers not defined.
return Promise.resolve(infoText);
}

View File

@@ -54,7 +54,7 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
fontWeight: 600,
height: '100%',
[[ '& > *:first-of-type', '& > *:last-of-type' ] as any]: {
[[ '& > *:first-child', '& > *:last-child' ] as any]: {
flexShrink: 0
},
@@ -116,11 +116,11 @@ const useStyles = makeStyles<IStylesProps>()((theme, { isChatOpen }) => {
antiCollapse: {
fontSize: 0,
'&:first-of-type': {
'&:first-child': {
display: 'none'
},
'&:first-of-type + *': {
'&:first-child + *': {
marginTop: 0
}
},

View File

@@ -7,7 +7,7 @@ import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import { getSoundFileSrc } from '../base/media/functions';
import { getLocalParticipant, getRemoteParticipants } from '../base/participants/functions';
import { registerSound, unregisterSound } from '../base/sounds/actions';
import { isEmbedded } from '../base/util/embedUtils';
import { isEmbedded, isEmbeddedFromSameDomain } from '../base/util/embedUtils';
import { isSpotTV } from '../base/util/spot';
import { isInBreakoutRoom as isInBreakoutRoomF } from '../breakout-rooms/functions';
import { isEnabled as isDropboxEnabled } from '../dropbox/functions';
@@ -153,7 +153,7 @@ export function getSessionStatusToShow(state: IReduxState, mode: string): string
* @returns {boolean} - Whether local recording is supported or not.
*/
export function supportsLocalRecording() {
return LocalRecordingManager.isSupported() && !isEmbedded();
return LocalRecordingManager.isSupported() && (!isEmbedded() || isEmbeddedFromSameDomain());
}
/**

View File

@@ -86,7 +86,7 @@ const useStyles = makeStyles()(theme => {
slider: {
// Use an additional class here to override global CSS specificity
'&.custom-slider': {
WebkitAppearance: 'none',
'-webkit-appearance': 'none',
background: 'transparent',
height,
left: 0,
@@ -104,11 +104,11 @@ const useStyles = makeStyles()(theme => {
},
'&::-webkit-slider-runnable-track': {
WebkitAppearance: 'none',
'-webkit-appearance': 'none',
...inputTrack
},
'&::-webkit-slider-thumb': {
WebkitAppearance: 'none',
'-webkit-appearance': 'none',
position: 'relative',
top: -6,
...inputThumb

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { IconCalendar, IconGear, IconRestore } from '../base/icons/svg';
import BaseTheme from '../base/ui/components/BaseTheme';
import BaseTheme from '../base/ui/components/BaseTheme.native';
import TabIcon from './components/TabIcon';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import ReactDOM from 'react-dom';
import { App } from './features/app/components/App.web';
import { getLogger } from './features/base/logging/functions';
@@ -72,17 +72,14 @@ globalNS.entryPoints = {
WHITEBOARD: WhiteboardApp
};
const _roots = new Map();
globalNS.renderEntryPoint = ({
Component,
props = {},
elementId = 'react'
}) => {
const element = document.getElementById(elementId);
if (!_roots.has(elementId)) {
_roots.set(elementId, createRoot(element));
}
_roots.get(elementId).render(<Component { ...props } />);
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.render(
<Component { ...props } />,
document.getElementById(elementId)
);
};

View File

@@ -95,26 +95,27 @@ module:hook("pre-iq/full", function(event)
local room_jid = jid.bare(stanza.attr.to);
local room_real_jid = room_jid_match_rewrite(room_jid);
local room = main_muc_service.get_room_from_jid(room_real_jid);
local is_sender_in_room = room:get_occupant_jid(stanza.attr.from) ~= nil;
if not room or not is_sender_in_room then
module:log("warn", "Filtering stanza dial, stanza:%s", tostring(stanza));
session.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end
local feature = dial.attr.to == 'jitsi_meet_transcribe' and 'transcription' or 'outbound-call';
local is_session_allowed = is_feature_allowed(
local error_message = nil;
if not room or room:get_occupant_jid(stanza.attr.from) == nil then
error_message = "not in room";
elseif roomName == nil then
error_message = OUT_ROOM_NAME_ATTR_NAME.." header missing";
elseif roomName ~= room_jid then
error_message = OUT_ROOM_NAME_ATTR_NAME.." header mismatch";
elseif (token ~= nil and not token_util:verify_room(session, room_real_jid)) then
error_message = "no token or token room mismatch";
elseif not is_feature_allowed(
feature,
session.jitsi_meet_context_features,
room:get_affiliation(stanza.attr.from) == 'owner');
room:get_affiliation(stanza.attr.from) == 'owner') then
if roomName == nil
or roomName ~= room_jid
or (token ~= nil and not token_util:verify_room(session, room_real_jid))
or not is_session_allowed
then
module:log("warn", "Filtering stanza dial, stanza:%s", tostring(stanza));
error_message = "feature not allowed";
end
if error_message then
module:log("warn", "Filtering stanza dial, %s, stanza:%s", error_message, tostring(stanza));
session.send(st.error_reply(stanza, "auth", "forbidden"));
return true;
end

View File

@@ -33,7 +33,7 @@ function insertTextMsg(id, msg) {
const el = document.getElementById(id);
if (el) {
el.innerHTML = msg;
el.textContent = msg;
}
}

View File

@@ -35,6 +35,8 @@ export const P1 = 'p1';
export const P2 = 'p2';
export const P3 = 'p3';
export const P4 = 'p4';
export const P5 = 'p5';
export const P6 = 'p6';
/**
* Participant.

View File

@@ -22,6 +22,12 @@ const defaultExpectations = {
liveStreamingEnabled: true,
recordingEnabled: true,
transcriptionEnabled: true,
transcription: {
// Expect "async" transcription to be enabled.
asyncTranscription: true,
// Expect the JaaS transcription webhooks to fire in "async" mode.
asyncTranscriptionWebhook: false
},
/**
* Whether the jaas account is configured with the account-level setting to allow unauthenticated users to join.
*/

View File

@@ -1,4 +1,4 @@
import { P1, P2, P3, P4, Participant } from './Participant';
import { P1, P2, P3, P4, P5, P6, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';
@@ -122,6 +122,50 @@ export async function ensureFourParticipants(options?: IJoinOptions): Promise<vo
]);
}
/**
* Ensure that there are six participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureSixParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);
// Join participants in batches
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);
await Promise.all([
joinParticipant({ name: P5 }, options),
joinParticipant({ name: P6 }, options)
]);
if (options?.skipInMeetingChecks) {
return Promise.resolve();
}
await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected(),
ctx.p5.waitForIceConnected(),
ctx.p6.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
ctx.p5.waitForSendReceiveData().then(() => ctx.p5.waitForRemoteStreams(1)),
ctx.p6.waitForSendReceiveData().then(() => ctx.p6.waitForRemoteStreams(1))
]);
}
/**
* Ensure that there are two participants.
*
@@ -244,10 +288,16 @@ export async function checkForScreensharingTile(sharer: Participant, observer: P
}
/**
* Hangs up all participants (p1, p2, p3 and p4)
* Hangs up all participants (p1, p2, p3, p4, p5, and p6)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup(), ctx.p3?.hangup(), ctx.p4?.hangup() ]
.map(p => p ?? Promise.resolve()));
return Promise.all([
ctx.p1?.hangup(),
ctx.p2?.hangup(),
ctx.p3?.hangup(),
ctx.p4?.hangup(),
ctx.p5?.hangup(),
ctx.p6?.hangup()
].map(p => p ?? Promise.resolve()));
}

View File

@@ -7,13 +7,16 @@ import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The up-to-four browser instances provided by the framework. These can be initialized using
* The up-to-six browser instances provided by the framework. These can be initialized using
* ensureOneParticipant, ensureTwoParticipants, etc. from participants.ts.
**/
p1: Participant;
p2: Participant;
p3: Participant;
p4: Participant;
p5: Participant;
p6: Participant;
/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
@@ -39,7 +42,7 @@ export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4';
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6';
/** An optional token to use. */
token?: IToken;
};

View File

@@ -13,7 +13,9 @@ setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2' ]
});
for (const asyncTranscriptions of [ false, true ]) {
const asyncTranscriptionValues = expectations.jaas.transcription.asyncTranscription ? [ false, true ] : [ false ];
for (const asyncTranscriptions of asyncTranscriptionValues) {
describe(`Transcription (async=${asyncTranscriptions})`, () => {
let p1: Participant, p2: Participant;
let webhooksProxy: WebhookProxy;
@@ -66,7 +68,7 @@ for (const asyncTranscriptions of [ false, true ]) {
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
@@ -87,7 +89,7 @@ for (const asyncTranscriptions of [ false, true ]) {
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
@@ -132,7 +134,7 @@ for (const asyncTranscriptions of [ false, true ]) {
expect(e.value.on).toBe(true);
});
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
@@ -168,15 +170,17 @@ for (const asyncTranscriptions of [ false, true ]) {
// let's wait for destroy event before waiting for those that depends on it
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
if (!asyncTranscriptions || expectations.jaas.transcription.asyncTranscriptionWebhook) {
const event: {
data: {
preAuthenticatedLink: string;
};
eventType: string;
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
expect(event.data.preAuthenticatedLink).toBeDefined();
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
expect(event.data.preAuthenticatedLink).toBeDefined();
}
});
});
}
@@ -186,10 +190,9 @@ for (const asyncTranscriptions of [ false, true ]) {
* @param p1
* @param p2
* @param webhooksProxy
* @param expectName Whether to expect the events to contain the name of the participant. Currently, async
* transcriptions do not include the name. TODO: remove this parameter when async transcription events are fixed.
* @param asyncTranscription Whether async transciptions are used.
*/
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, expectName = true) {
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, asyncTranscription = false) {
const p1Promise = p1.driver.waitUntil(() => p1.getIframeAPI()
.getEventResult('transcriptionChunkReceived'), {
timeout: 60000,
@@ -202,7 +205,24 @@ async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksPr
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
});
const webhookPromise = async () => {
const [ p1Event, p2Event ] = await Promise.all([ p1Promise, p2Promise ]);
const p1Id = await p1.getEndpointId();
const p1Transcript = p1Event.data.stable || p1Event.data.final;
const p2Transcript = p2Event.data.stable || p2Event.data.final;
expect(p2Transcript.includes(p1Transcript) || p1Transcript.includes(p2Transcript)).toBe(true);
expect(p2Event.data.language).toBe(p1Event.data.language);
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
expect(p1Event.data.participant.id).toBe(p1Id);
expect(p2Event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(p1Event.data.participant.name).toBe(p1.name);
expect(p2Event.data.participant.name).toBe(p1.name);
}
if (!asyncTranscription || expectations.jaas.transcription.asyncTranscriptionWebhook) {
const event: {
data: {
final: string;
@@ -219,34 +239,14 @@ async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksPr
expect(event.eventType).toBe('TRANSCRIPTION_CHUNK_RECEIVED');
event.data.stable = event.data.final;
const webhookTranscript = event.data.final;
return event;
};
const [ p1Event, p2Event, webhookEvent ] = await Promise.all([ p1Promise, p2Promise, await webhookPromise() ]);
const p1Id = await p1.getEndpointId();
const p1Transcript = p1Event.data.stable || p1Event.data.final;
const p2Transcript = p2Event.data.stable || p2Event.data.final;
const webhookTranscript = webhookEvent.data.stable || webhookEvent.data.final;
expect(p2Transcript.includes(p1Transcript) || p1Transcript.includes(p2Transcript)).toBe(true);
expect(webhookTranscript.includes(p1Transcript) || p1Transcript.includes(webhookTranscript)).toBe(true);
expect(p2Event.data.language).toBe(p1Event.data.language);
expect(webhookEvent.data.language).toBe(p1Event.data.language);
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
expect(webhookEvent.data.messageID).toBe(p1Event.data.messageID);
expect(p1Event.data.participant.id).toBe(p1Id);
expect(p2Event.data.participant.id).toBe(p1Id);
expect(webhookEvent.data.participant.id).toBe(p1Id);
if (expectName) {
expect(p1Event.data.participant.name).toBe(p1.name);
expect(p2Event.data.participant.name).toBe(p1.name);
expect(webhookEvent.data.participant.name).toBe(p1.name);
expect(webhookTranscript.includes(p1Transcript) || p1Transcript.includes(webhookTranscript)).toBe(true);
expect(event.data.language).toBe(p1Event.data.language);
expect(event.data.messageID).toBe(p1Event.data.messageID);
expect(event.data.participant.id).toBe(p1Id);
if (!asyncTranscription) {
expect(event.data.participant.name).toBe(p1.name);
}
}
}

View File

@@ -1,16 +1,20 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import { ensureThreeParticipants } from '../../helpers/participants';
import {
checkForScreensharingTile,
ensureSixParticipants,
ensureThreeParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import { muteAudioAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3' ]
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ]
});
describe('Active speaker', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await muteAudioAndCheck(p1, p2);
@@ -30,6 +34,306 @@ describe('Active speaker', () => {
await assertOneDominantSpeaker(p1);
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
await hangupAllParticipants();
});
/**
* Test that the dominant speaker appears in the filmstrip in stage view
* even when alphabetically last with limited visible slots.
* This tests the fix for the bug where dominant speakers at the bottom of
* the alphabetically sorted list would not appear when slots were limited.
*
* Note: This test verifies filmstrip ordering via Redux state
* (visibleRemoteParticipants), not large video behavior.
*/
it.skip('testDominantSpeakerInFilmstripWithLimitedSlots', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots to 2-3 tiles
// This creates the condition where not all participants fit in the filmstrip
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names to create alphabetical ordering
// Names chosen so p6 ("Zoe") is alphabetically last: Alice, Bob, Charlie, Eve, Frank, Zoe
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Test with multiple speakers: Eve (p4), Frank (p5), and Zoe (p6)
// This verifies the fix works for different alphabetical positions
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify that the participant appears in the visible remote participants
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker: ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we actually have slot limitation (fewer visible than total)
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// Assert that the dominant speaker is in the visible participants
// This is the key test - even though they may be alphabetically late and slots are limited,
// they should still be visible because the fix reserves a slot for dominant speaker
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify the dominant speaker thumbnail is visible in the filmstrip
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} dominant speaker thumbnail not visible in filmstrip`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(2000);
}
await hangupAllParticipants();
});
/**
* Test dominant speaker in filmstrip with screensharing active.
* Verifies that dominant speaker is still visible when screen shares
* take up some of the visible slots.
*/
it.skip('testDominantSpeakerWithScreensharing', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// Start screensharing from p2
await p2.getToolbar().clickDesktopSharingButton();
await checkForScreensharingTile(p2, p1);
// Test with multiple speakers while screensharing is active
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) as dominant speaker with screensharing`);
// Make this participant the dominant speaker by unmuting
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Verify dominant speaker is still visible in filmstrip despite screenshare
const filmstripState = await getFilmstripState(p1);
await p1.log(`Dominant speaker (with screenshare): ${filmstripState.dominantSpeaker}`);
await p1.log(`Visible remote participants with screenshare: ${JSON.stringify(filmstripState.visibleRemoteParticipants)}`);
await p1.log(`${name} endpoint ID: ${participantId}`);
await p1.log(`Total remote: ${filmstripState.remoteParticipants.length}, Visible: ${filmstripState.visibleRemoteParticipants.length}`);
// Verify we have slot limitation even with screensharing
expect(filmstripState.visibleRemoteParticipants.length).toBeLessThan(filmstripState.remoteParticipants.length);
// The dominant speaker should still be in the visible participants despite screenshare taking slots
expect(filmstripState.visibleRemoteParticipants).toContain(participantId);
// Verify thumbnail visibility
await p1.driver.$(`//span[@id='participant_${participantId}']`).waitForDisplayed({
timeout: 5_000,
timeoutMsg: `${name} not visible with screensharing active`
});
// Mute this participant back before testing the next one
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Clean up - stop screensharing
await p2.getToolbar().clickStopDesktopSharingButton();
await hangupAllParticipants();
});
/**
* Test that filmstrip maintains stable ordering when multiple speakers alternate.
* Verifies that the alphabetical sorting prevents visual reordering when the same
* set of speakers take turns speaking.
*/
it.skip('testFilmstripStableOrderingWithMultipleSpeakers', async () => {
await ensureSixParticipants({
configOverwrite: {
startWithAudioMuted: true
}
});
const { p1, p2, p3, p4, p5, p6 } = ctx;
// Resize p1's window to limit filmstrip slots
await p1.driver.setWindowSize(1024, 600);
await p1.driver.pause(1000); // Wait for layout to adjust
// Set display names
await setAlphabeticalDisplayNames(p1, p2, p3, p4, p5, p6);
// First, have Eve, Frank, and Zoe all speak to get them into the active speakers list
const speakersToTest = [
{ participant: p4, name: 'Eve' },
{ participant: p5, name: 'Frank' },
{ participant: p6, name: 'Zoe' }
];
await p1.log('Initial round: getting all three speakers into active speakers list');
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`${name} (${participantId}) speaking for the first time`);
await participant.getToolbar().clickAudioUnmuteButton();
await waitForDominantSpeaker(p1, participantId, name);
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
// Now cycle through them again and verify they maintain alphabetical order (Eve, Frank, Zoe)
await p1.log('Second round: verifying stable alphabetical ordering when speakers alternate');
const states = [];
for (const { participant, name } of speakersToTest) {
const participantId = await participant.getEndpointId();
await p1.log(`Testing ${name} (${participantId}) for stable ordering`);
// Make this participant the dominant speaker
await participant.getToolbar().clickAudioUnmuteButton();
// Wait for the dominant speaker to be detected
await waitForDominantSpeaker(p1, participantId, name);
// Capture filmstrip state
const filmstripState = await getFilmstripState(p1);
states.push({ name, id: participantId, state: filmstripState });
// Mute back
await participant.getToolbar().clickAudioMuteButton();
await p1.driver.pause(1000);
}
const [ eveState, frankState, zoeState ] = states;
// Helper function to get participant names in the order they appear
const getVisibleParticipantNames = async (visibleIds: string[]) => {
return await p1.execute(ids => {
const state = APP.store.getState();
const participants = state['features/base/participants'];
return ids.map(id => {
const participant = participants.remote.get(id);
return participant?.name || 'Unknown';
});
}, visibleIds);
};
// Get the names of visible participants for each state
const eveVisibleNames = await getVisibleParticipantNames(eveState.state.visibleRemoteParticipants);
const frankVisibleNames = await getVisibleParticipantNames(frankState.state.visibleRemoteParticipants);
const zoeVisibleNames = await getVisibleParticipantNames(zoeState.state.visibleRemoteParticipants);
await p1.log(`Visible participants when Eve is dominant: ${JSON.stringify(eveVisibleNames)}`);
await p1.log(`Visible participants when Frank is dominant: ${JSON.stringify(frankVisibleNames)}`);
await p1.log(`Visible participants when Zoe is dominant: ${JSON.stringify(zoeVisibleNames)}`);
await p1.log(`Eve visible count: ${eveState.state.visibleRemoteParticipants.length}, total remote: ${eveState.state.remoteParticipants.length}`);
await p1.log(`Frank visible count: ${frankState.state.visibleRemoteParticipants.length}, total remote: ${frankState.state.remoteParticipants.length}`);
await p1.log(`Zoe visible count: ${zoeState.state.visibleRemoteParticipants.length}, total remote: ${zoeState.state.remoteParticipants.length}`);
// Verify that each dominant speaker appears in visible participants
expect(eveState.state.visibleRemoteParticipants).toContain(eveState.id);
expect(frankState.state.visibleRemoteParticipants).toContain(frankState.id);
expect(zoeState.state.visibleRemoteParticipants).toContain(zoeState.id);
// Helper function to get the relative order of Eve, Frank, and Zoe
const getSpeakersOrder = (names: string[]) => {
return names.filter(n => [ 'Eve', 'Frank', 'Zoe' ].includes(n));
};
const eveOrder = getSpeakersOrder(eveVisibleNames);
const frankOrder = getSpeakersOrder(frankVisibleNames);
const zoeOrder = getSpeakersOrder(zoeVisibleNames);
await p1.log(`Speakers order when Eve is dominant: ${JSON.stringify(eveOrder)}`);
await p1.log(`Speakers order when Frank is dominant: ${JSON.stringify(frankOrder)}`);
await p1.log(`Speakers order when Zoe is dominant: ${JSON.stringify(zoeOrder)}`);
// Verify that the dominant speaker is always in the visible list (this tests the bug fix)
expect(eveOrder).toContain('Eve');
expect(frankOrder).toContain('Frank');
expect(zoeOrder).toContain('Zoe');
// Helper to check if array is alphabetically sorted
const isAlphabeticallySorted = (names: string[]) => {
for (let i = 0; i < names.length - 1; i++) {
if (names[i].localeCompare(names[i + 1]) > 0) {
return false;
}
}
return true;
};
// Verify that whatever speakers ARE visible maintain alphabetical order
// This is the key test - when the same speakers alternate, visible speakers stay in alphabetical order
expect(isAlphabeticallySorted(eveOrder)).toBe(true);
expect(isAlphabeticallySorted(frankOrder)).toBe(true);
expect(isAlphabeticallySorted(zoeOrder)).toBe(true);
// Additionally verify order consistency: if multiple speakers are visible in multiple states,
// their relative order should be the same
// For example, if Eve and Frank are both visible when Zoe speaks, they should be [Eve, Frank]
if (eveOrder.includes('Frank') && frankOrder.includes('Eve')) {
// Both Eve and Frank visible in both states
const eveAndFrankInEveState = eveOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
const eveAndFrankInFrankState = frankOrder.filter(n => [ 'Eve', 'Frank' ].includes(n));
expect(eveAndFrankInEveState).toEqual(eveAndFrankInFrankState);
}
await p1.log('Filmstrip maintains alphabetical ordering of visible speakers when dominant speaker changes');
await hangupAllParticipants();
});
});
@@ -95,3 +399,81 @@ async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(
'//span[not(contains(@class, "tile-view"))]//span[contains(@class,"dominant-speaker")]').length).toBe(1);
}
/**
* Wait for a participant to be detected as the dominant speaker.
*
* @param {Participant} observer - The participant observing the dominant speaker state.
* @param {string} participantId - The endpoint ID of the expected dominant speaker.
* @param {string} participantName - The name of the participant for logging.
*/
async function waitForDominantSpeaker(
observer: Participant,
participantId: string,
participantName: string
): Promise<void> {
await observer.driver.waitUntil(
async () => {
const state = await observer.execute(() => {
const participants = APP.store.getState()['features/base/participants'];
return participants.dominantSpeaker;
});
return state === participantId;
},
{
timeout: 10000,
timeoutMsg: `${participantName} (${participantId}) was not detected as dominant speaker within 10 seconds`
}
);
// Wait a bit more for filmstrip state to update after dominant speaker changes
await observer.driver.pause(1000);
}
/**
* Get the current filmstrip state from Redux.
*
* @param {Participant} participant - The participant to query.
* @returns {Promise<FilmstripState>} The filmstrip state.
*/
async function getFilmstripState(participant: Participant): Promise<{
dominantSpeaker: string | null;
remoteParticipants: string[];
visibleRemoteParticipants: string[];
}> {
return await participant.execute(() => {
const state = APP.store.getState();
const filmstrip = state['features/filmstrip'];
const participants = state['features/base/participants'];
return {
dominantSpeaker: participants.dominantSpeaker,
remoteParticipants: filmstrip.remoteParticipants,
visibleRemoteParticipants: Array.from(filmstrip.visibleRemoteParticipants)
};
});
}
/**
* Set display names for all 6 participants to create alphabetical ordering.
*/
async function setAlphabeticalDisplayNames(
p1: Participant,
p2: Participant,
p3: Participant,
p4: Participant,
p5: Participant,
p6: Participant
): Promise<void> {
await p1.setLocalDisplayName('Alice');
await p2.setLocalDisplayName('Bob');
await p3.setLocalDisplayName('Charlie');
await p4.setLocalDisplayName('Eve');
await p5.setLocalDisplayName('Frank');
await p6.setLocalDisplayName('Zoe');
// Wait for display names to propagate
await p1.driver.pause(2000);
}

View File

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

View File

@@ -68,7 +68,7 @@ const specs = [
*/
function generateCapabilitiesFromSpecs(): Record<string, any> {
const allSpecFiles: string[] = [];
const browsers = [ 'p1', 'p2', 'p3', 'p4' ];
const browsers = [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ];
for (const pattern of specs) {
const matches = glob.sync(pattern, { cwd: path.join(__dirname) });
@@ -87,7 +87,9 @@ function generateCapabilitiesFromSpecs(): Record<string, any> {
p1: new Set(),
p2: new Set(),
p3: new Set(),
p4: new Set()
p4: new Set(),
p5: new Set(),
p6: new Set()
};
for (const file of allSpecFiles) {