mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-02-20 04:40:20 +00:00
Compare commits
19 Commits
fix/consol
...
mobile-26.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8dd08b1c0 | ||
|
|
586bcc2677 | ||
|
|
c9a220e1c3 | ||
|
|
5f11e2e9f4 | ||
|
|
45ee515b6b | ||
|
|
56f7792604 | ||
|
|
230559c493 | ||
|
|
900bece988 | ||
|
|
a06a338de5 | ||
|
|
eb1e4f01e7 | ||
|
|
641a401b4b | ||
|
|
6e3c256905 | ||
|
|
36a755776e | ||
|
|
7bd38be8a4 | ||
|
|
c88bfa1b9a | ||
|
|
6a443b0f8f | ||
|
|
b4cc466608 | ||
|
|
bde8dca825 | ||
|
|
2ab86cffb8 |
@@ -32,5 +32,5 @@ newArchEnabled=false
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
hermesEnabled=true
|
||||
|
||||
appVersion=99.0.0
|
||||
sdkVersion=0.0.0
|
||||
appVersion=26.1.0
|
||||
sdkVersion=12.1.0
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>99.0.0</string>
|
||||
<string>26.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>99.0.0</string>
|
||||
<string>26.1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>99.0.0</string>
|
||||
<string>12.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>99.0.0</string>
|
||||
<string>12.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "j’ai 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 l’appel. 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
2
modules/API/external/external_api.js
vendored
2
modules/API/external/external_api.js
vendored
@@ -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',
|
||||
|
||||
4008
react-native-sdk/package-lock.json
generated
Normal file
4008
react-native-sdk/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jitsi/react-native-sdk",
|
||||
"version": "0.0.0",
|
||||
"version": "12.1.0",
|
||||
"description": "React Native SDK for Jitsi Meet.",
|
||||
"main": "index.tsx",
|
||||
"license": "Apache-2.0",
|
||||
@@ -11,85 +11,83 @@
|
||||
"url": "git+https://github.com/jitsi/jitsi-meet.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "0.0.0",
|
||||
"@jitsi/js-utils": "0.0.0",
|
||||
"@jitsi/logger": "0.0.0",
|
||||
"@jitsi/rtcstats": "0.0.0",
|
||||
"@react-navigation/bottom-tabs": "0.0.0",
|
||||
"@react-navigation/elements": "0.0.0",
|
||||
"@react-navigation/material-top-tabs": "0.0.0",
|
||||
"@react-navigation/native": "0.0.0",
|
||||
"@react-navigation/stack": "0.0.0",
|
||||
"@stomp/stompjs": "0.0.0",
|
||||
"@xmldom/xmldom": "0.0.0",
|
||||
"abab": "0.0.0",
|
||||
"base64-js": "0.0.0",
|
||||
"dayjs": "0.0.0",
|
||||
"grapheme-splitter": "0.0.0",
|
||||
"i18n-iso-countries": "0.0.0",
|
||||
"i18next": "0.0.0",
|
||||
"js-md5": "0.0.0",
|
||||
"i18next-http-backend": "0.0.0",
|
||||
"js-sha512": "0.0.0",
|
||||
"jwt-decode": "0.0.0",
|
||||
"lib-jitsi-meet": "0.0.0",
|
||||
"lodash-es": "0.0.0",
|
||||
"optional-require": "0.0.0",
|
||||
"promise.allsettled": "0.0.0",
|
||||
"promise.withresolvers": "0.0.0",
|
||||
"punycode": "0.0.0",
|
||||
"react-emoji-render": "0.0.0",
|
||||
"react-i18next": "0.0.0",
|
||||
"react-linkify": "0.0.0",
|
||||
"react-native-dialog": "0.0.0",
|
||||
"react-native-paper": "0.0.0",
|
||||
"react-native-svg-transformer": "0.0.0",
|
||||
"react-native-tab-view": "0.0.0",
|
||||
"react-native-url-polyfill": "0.0.0",
|
||||
"react-native-youtube-iframe": "0.0.0",
|
||||
"react-redux": "0.0.0",
|
||||
"redux": "0.0.0",
|
||||
"redux-thunk": "0.0.0",
|
||||
"text-encoding": "0.0.0",
|
||||
"unorm": "0.0.0",
|
||||
"util": "0.0.0",
|
||||
"uuid": "0.0.0",
|
||||
"zxcvbn": "0.0.0"
|
||||
"@braintree/sanitize-url": "7.0.0",
|
||||
"@jitsi/js-utils": "2.6.7",
|
||||
"@jitsi/logger": "2.1.1",
|
||||
"@react-navigation/bottom-tabs": "6.6.0",
|
||||
"@react-navigation/elements": "1.3.30",
|
||||
"@react-navigation/material-top-tabs": "6.6.13",
|
||||
"@react-navigation/native": "6.1.17",
|
||||
"@react-navigation/stack": "6.4.0",
|
||||
"@stomp/stompjs": "7.0.0",
|
||||
"@xmldom/xmldom": "0.8.7",
|
||||
"abab": "2.0.6",
|
||||
"base64-js": "1.5.1",
|
||||
"dayjs": "1.11.13",
|
||||
"grapheme-splitter": "1.0.4",
|
||||
"i18n-iso-countries": "6.8.0",
|
||||
"i18next": "17.0.6",
|
||||
"js-md5": "0.6.1",
|
||||
"i18next-http-backend": "2.2.1",
|
||||
"js-sha512": "0.8.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2124.0.0+80df84a1/lib-jitsi-meet.tgz",
|
||||
"lodash-es": "4.17.21",
|
||||
"optional-require": "1.0.3",
|
||||
"promise.withresolvers": "1.0.3",
|
||||
"punycode": "2.3.0",
|
||||
"react-emoji-render": "2.0.1",
|
||||
"react-i18next": "10.11.4",
|
||||
"react-linkify": "1.0.0-alpha",
|
||||
"react-native-dialog": "https://github.com/jitsi/react-native-dialog/releases/download/v9.2.2-jitsi.1/react-native-dialog-9.2.2.tgz",
|
||||
"react-native-paper": "5.10.3",
|
||||
"react-native-svg-transformer": "1.2.0",
|
||||
"react-native-tab-view": "3.5.2",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-youtube-iframe": "2.3.0",
|
||||
"react-redux": "7.2.9",
|
||||
"redux": "4.0.4",
|
||||
"redux-thunk": "2.4.1",
|
||||
"text-encoding": "0.7.0",
|
||||
"unorm": "1.6.0",
|
||||
"util": "0.12.1",
|
||||
"uuid": "8.3.2",
|
||||
"zxcvbn": "4.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@amplitude/analytics-react-native": "0.0.0",
|
||||
"@giphy/react-native-sdk": "0.0.0",
|
||||
"@react-native-async-storage/async-storage": "0.0.0",
|
||||
"@react-native-clipboard/clipboard": "0.0.0",
|
||||
"@react-native-community/netinfo": "0.0.0",
|
||||
"@react-native-community/slider": "0.0.0",
|
||||
"@react-native-google-signin/google-signin": "0.0.0",
|
||||
"@sayem314/react-native-keep-awake": "0.0.0",
|
||||
"react-native": "0.0.0",
|
||||
"@amplitude/analytics-react-native": "1.4.13",
|
||||
"@giphy/react-native-sdk": "4.1.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-clipboard/clipboard": "1.14.3",
|
||||
"@react-native-community/netinfo": "11.1.0",
|
||||
"@react-native-community/slider": "4.5.6",
|
||||
"@react-native-google-signin/google-signin": "10.1.0",
|
||||
"@sayem314/react-native-keep-awake": "1.3.1",
|
||||
"react-native": "~0.77.0",
|
||||
"react": "*",
|
||||
"react-native-background-timer": "0.0.0",
|
||||
"react-native-calendar-events": "0.0.0",
|
||||
"react-native-default-preference": "0.0.0",
|
||||
"react-native-device-info": "0.0.0",
|
||||
"react-native-get-random-values": "0.0.0",
|
||||
"react-native-gesture-handler": "0.0.0",
|
||||
"react-native-pager-view": "0.0.0",
|
||||
"react-native-performance": "0.0.0",
|
||||
"react-native-orientation-locker": "0.0.0",
|
||||
"react-native-safe-area-context": "0.0.0",
|
||||
"react-native-screens": "0.0.0",
|
||||
"react-native-sound": "0.0.0",
|
||||
"react-native-splash-view": "0.0.0",
|
||||
"react-native-svg": "0.0.0",
|
||||
"react-native-video": "0.0.0",
|
||||
"react-native-background-timer": "https://github.com/jitsi/react-native-background-timer.git#d180dfaa4486ae3ee17d01242db92cb3195f4718",
|
||||
"react-native-calendar-events": "https://github.com/jitsi/react-native-calendar-events.git#47f068dedfed7c0f72042e093f688eb11624eb7b",
|
||||
"react-native-default-preference": "https://github.com/jitsi/react-native-default-preference.git#c9bf63bdc058e3fa2aa0b87b1ee1af240f44ed02",
|
||||
"react-native-device-info": "12.1.0",
|
||||
"react-native-get-random-values": "1.11.0",
|
||||
"react-native-gesture-handler": "2.24.0",
|
||||
"react-native-pager-view": "6.8.1",
|
||||
"react-native-performance": "5.1.2",
|
||||
"react-native-orientation-locker": "https://github.com/jitsi/react-native-orientation-locker.git#fe095651d819cf134624f786b61fc8667862178a",
|
||||
"react-native-safe-area-context": "5.6.1",
|
||||
"react-native-screens": "4.11.1",
|
||||
"react-native-sound": "https://github.com/jitsi/react-native-sound.git#ea13c97b5c2a4ff5e0d9bacbd9ff5e4457fe2c3c",
|
||||
"react-native-splash-view": "0.0.18",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-video": "6.13.0",
|
||||
"react-native-watch-connectivity": "0.0.0",
|
||||
"react-native-webrtc": "0.0.0",
|
||||
"react-native-webview": "0.0.0",
|
||||
"react-native-worklets-core": "0.0.0"
|
||||
"react-native-webrtc": "124.0.7",
|
||||
"react-native-webview": "13.13.5",
|
||||
"react-native-worklets-core": "https://github.com/jitsi/react-native-worklets-core.git#8c5dfab2a5907305da8971696a781b60f0f9cb18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "0.0.0",
|
||||
"@babel/plugin-proposal-optional-chaining": "0.0.0"
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
|
||||
"@babel/plugin-proposal-optional-chaining": "7.21.0"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node sdk_instructions.js",
|
||||
@@ -101,4 +99,4 @@
|
||||
"keywords": [
|
||||
"react-native"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,3 @@ export function isIpadMobileBrowser() {
|
||||
// @ts-ignore
|
||||
return isIosMobileBrowser() && Platform.isPad;
|
||||
}
|
||||
|
||||
1
react/features/base/environment/utils.native.ts
Normal file
1
react/features/base/environment/utils.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './utils.any';
|
||||
37
react/features/base/environment/utils.web.ts
Normal file
37
react/features/base/environment/utils.web.ts
Normal 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';
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -220,9 +220,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 +356,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 +431,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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -25,7 +25,11 @@ export const commonStyles = (theme: Theme) => {
|
||||
'--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
|
||||
'--overflow-menu-item-disabled-color': theme.palette.overflowMenuItemDisabled,
|
||||
'--reactions-menu-background': theme.palette.reactionsMenuBackground,
|
||||
'--reactions-menu-button-toggled': theme.palette.reactionsMenuButtonToggled,
|
||||
'--reactions-menu-box-shadow-1': theme.palette.reactionsMenuBoxShadow1,
|
||||
'--reactions-menu-box-shadow-2': theme.palette.reactionsMenuBoxShadow2
|
||||
},
|
||||
|
||||
'.empty-list': {
|
||||
|
||||
@@ -313,6 +313,9 @@ export interface IPalette {
|
||||
pollsVotersText: string;
|
||||
reactionsMenuBackground: string;
|
||||
reactionsMenuBorder: string;
|
||||
reactionsMenuButtonToggled: string;
|
||||
reactionsMenuBoxShadow1: string;
|
||||
reactionsMenuBoxShadow2: string;
|
||||
recordingBackground: string;
|
||||
recordingHighlightButton: string;
|
||||
recordingHighlightButtonDisabled: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ function insertTextMsg(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
|
||||
if (el) {
|
||||
el.innerHTML = msg;
|
||||
el.textContent = msg;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user