Compare commits

..

6 Commits

Author SHA1 Message Date
bgrozev
945e42a923 test: Fix transcription test. (#16974)
Fix waiting for transcription to be turned off. Re-enabling transcription was sometimes started before jigasi had left the room, resulting in a failure.
2026-02-20 18:12:24 -06:00
Boris Grozev
8b4528e3c5 test: Enable retries for the kick test, it's failing sporadically. 2026-02-20 18:12:22 -06:00
Jaya Allamsetty
5eca22fcb5 Revert "fix(filmstrip) Ensures dominant speaker is always visible in filmstrip (#16901)"
This reverts commit bde8dca825.
2026-02-19 09:26:44 -05:00
Jaya Allamsetty
d35d93900c Revert "fix(filmstrip) Excludes partially visible tiles for dominant speaker slot."
This reverts commit c88bfa1b9a.
2026-02-19 09:26:22 -05:00
Mihaela Dumitru
ece9b75d5e fix(ui): add semantic tokens for svg fills and preview (#16962) 2026-02-19 11:39:40 +02:00
Mihaela Dumitru
3b66476d97 fix(ui): more semantic tokens (#16955) 2026-02-18 16:47:54 +02:00
29 changed files with 202 additions and 716 deletions

View File

@@ -96,53 +96,23 @@ 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();
// 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;
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;
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
@@ -166,11 +136,10 @@ 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
debian/control vendored
View File

@@ -34,7 +34,7 @@ Description: Configuration for web serving of Jitsi Meet
Package: jitsi-meet-prosody
Architecture: all
Depends: openssl, prosody (>= 0.12.0) | prosody-trunk | prosody-0.12 | prosody-13.0, ca-certificates-java, lua-sec, lua-basexx, lua-luaossl, lua-cjson, lua-inspect
Depends: openssl, prosody (>= 0.12.0) | prosody-trunk | prosody-0.12 | prosody-13.0, lua-sec, lua-basexx, lua-luaossl, lua-cjson, lua-inspect
Replaces: jitsi-meet-tokens
Description: Prosody configuration for Jitsi Meet
Jitsi Meet is a WebRTC JavaScript application that uses Jitsi

View File

@@ -284,17 +284,13 @@ case "$1" in
# and drop the wait and the prosody restart
sleep 1
invoke-rc.d prosody restart || true
fi
;;
triggered)
for trigger in $2; do
if [ "$trigger" = "update-ca-certificates-java" ]; then
echo "Java certificates updated, restarting Jitsi components..."
# In case we had updated the certificates and restarted prosody, let's restart and the bridge and jicofo if possible
if [ -d /run/systemd/system ] && [ "$CERT_ADDED_TO_TRUST" = "true" ]; then
systemctl restart jitsi-videobridge2.service >/dev/null || true
systemctl restart jicofo.service >/dev/null || true
fi
done
exit 0
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)

View File

@@ -1 +0,0 @@
interest-noawait update-ca-certificates-java

View File

@@ -1,7 +1,7 @@
{
"addPeople": {
"accessibilityLabel": {
"meetingLink": "Meeting-link: {{url}}"
"meetingLink": "Meeting link: {{url}}"
},
"add": "Uitnodigen",
"addContacts": "Nodig uw contacten uit",
@@ -504,7 +504,7 @@
"sharedVideoDialogError": "Fout: Ongeldige of verboden URL",
"sharedVideoLinkPlaceholder": "YouTube-link of directe video-link",
"show": "Weergeven",
"start": "Starten ",
"start": "Start ",
"startLiveStreaming": "Livestream starten",
"startRecording": "Opname starten",
"startRemoteControlErrorMessage": "Er is een fout opgetreden tijdens het starten van de sessie van extern beheer.",
@@ -592,7 +592,6 @@
"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"
@@ -889,7 +888,7 @@
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) verwijderd door een andere deelnemer",
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) ingesteld door een ander deelnemer",
"raiseHandAction": "Hand opsteken",
"raisedHand": "Wil graag deelnemen.",
"raisedHand": "Wilgraag deelnemen.",
"raisedHands": "{{participantName}} en {{raisedHands}} meer mensen",
"reactionSounds": "Geluiden uitschakelen",
"reactionSoundsForAll": "Geluiden uitschakelen voor iedereen",
@@ -1097,7 +1096,7 @@
"setEmailLabel": "Gravatar e-mailadres",
"title": "Profiel"
},
"raisedHand": "Wil graag spreken",
"raisedHand": "Zou graag willen spreken",
"raisedHandsLabel": "Aantal handen omhoog",
"record": {
"already": {
@@ -1631,30 +1630,30 @@
"chatIndicator": "(toeschouwer)",
"joinMeeting": {
"description": "U bent momenteel een toeschouwer bij deze conferentie.",
"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."
"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."
},
"labelTooltip": "Aantal toeschouwers: {{count}}",
"labelTooltip": "Number of viewers: {{count}}",
"notification": {
"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"
"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"
},
"waitingMessage": "U zult deelnemen aan de vergadering zodra deze live is!"
"waitingMessage": "You'll join the meeting as soon as it is live!"
},
"volumeSlider": "Volume-schuifknop",
"volumeSlider": "Volume slider",
"welcomepage": {
"accessibilityLabel": {
"join": "Tik om deel te nemen",
"roomname": "Voer naam van ruimte in"
},
"addMeetingName": "Geef de vergadering een naam",
"addMeetingName": "Add Meeting name",
"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",
@@ -1674,16 +1673,16 @@
"join": "AANMAKEN / DEELNEMEN",
"logo": {
"calendar": "Agenda logo",
"desktopPreviewThumbnail": "Bureaubladvoorbeeld miniatuur",
"desktopPreviewThumbnail": "Desktop preview thumbnail",
"googleLogo": "Google logo",
"logoDeepLinking": "Jitsi meet logo",
"microsoftLogo": "Microsoft logo",
"policyLogo": "Policy logo"
},
"meetingsAccessibilityLabel": "Vergaderingen",
"mobileDownLoadLinkAndroid": "Download mobiele app voor Android",
"mobileDownLoadLinkFDroid": "Download mobiele app voor F-Droid",
"mobileDownLoadLinkIos": "Download mobiele app voor iOS",
"meetingsAccessibilityLabel": "Meetings",
"mobileDownLoadLinkAndroid": "Download mobile app for Android",
"mobileDownLoadLinkFDroid": "Download mobile app for F-Droid",
"mobileDownLoadLinkIos": "Download mobile app for iOS",
"moderatedMessage": "Of <a href=\"{{url}}\" rel=\"noopener noreferrer\" target=\"_blank\">boek een vergadering URL</a> van tevoren waar u de enige moderator bent.",
"privacy": "Privacy",
"recentList": "Recent",

View File

@@ -4,7 +4,7 @@ import { getCustomerDetails } from '../../jaas/actions.any';
import { getJaasJWT, isVpaasMeeting } from '../../jaas/functions';
import { navigateRoot } from '../../mobile/navigation/rootNavigationContainerRef';
import { screen } from '../../mobile/navigation/routes';
import { conferenceLeft } from '../conference/actions.native';
import { conferenceWillLeave } from '../conference/actions.native';
import { setJWT } from '../jwt/actions';
import { JitsiConnectionErrors } from '../lib-jitsi-meet';
@@ -60,7 +60,7 @@ export function connect(id?: string, password?: string) {
*/
export function hangup(_requestFeedback = false) {
return (dispatch: IStore['dispatch']) => {
dispatch(conferenceWillLeave());
dispatch(appNavigate(undefined));
dispatch(conferenceLeft());
};
}

View File

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

View File

@@ -68,7 +68,6 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
];
const DEFAULT_STATE = {
activeSpeakers: new Set<string>(),
dominantSpeaker: undefined,
fakeParticipants: new Map(),
local: undefined,
@@ -83,10 +82,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;
@@ -101,6 +100,7 @@ export interface IParticipantsState {
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}
/**
@@ -157,7 +157,22 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
const { participant } = action;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const activeSpeakers = new Set(previousSpeakers.filter((speakerId: string) => speakerId !== local?.id));
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]));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
@@ -168,7 +183,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
return {
...state,
dominantSpeaker: id, // @ts-ignore
activeSpeakers
speakersList: new Map(sortedSpeakersList)
};
}
@@ -423,7 +438,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
}
// Remove the participant from the list of speakers.
state.activeSpeakers.delete(id);
state.speakersList.has(id) && state.speakersList.delete(id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;

View File

@@ -221,9 +221,9 @@ export const colorMap = {
// Welcome Page
welcomeBackground: 'surface01', // Welcome page background (same as uiBackground)
welcomeCard: 'surface02', // Welcome page tab bar background
welcomeCard: 'ui01', // Welcome page tab bar background
welcomeTabActive: 'icon01', // Welcome page active tab icon
welcomeTabInactive: 'ui03', // Welcome page inactive tab icon
welcomeTabInactive: 'icon03', // Welcome page inactive tab icon
// ----- Form Components -----

View File

@@ -81,11 +81,7 @@ const useStyles = makeStyles()(theme => {
'&:focus': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.inputFieldFocus}`,
'&::placeholder': {
opacity: 0
}
boxShadow: `0px 0px 0px 2px ${theme.palette.inputFieldFocus}`
},
'&:disabled': {

View File

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

View File

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

View File

@@ -60,19 +60,16 @@ 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,
* fullyVisibleCount: number
* endIndex: number
* }}
*/
export function setVisibleRemoteParticipants(startIndex: number, endIndex: number, fullyVisibleCount?: number) {
export function setVisibleRemoteParticipants(startIndex: number, endIndex: number) {
return {
type: SET_VISIBLE_REMOTE_PARTICIPANTS,
startIndex,
endIndex,
fullyVisibleCount
endIndex
};
}

View File

@@ -10,7 +10,6 @@ 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,
@@ -191,7 +190,7 @@ class Filmstrip extends PureComponent<IProps> {
* @returns {void}
*/
_onViewableItemsChanged({ viewableItems = [] }: { viewableItems: ViewToken[]; }) {
const { _aspectRatio, _clientWidth, _clientHeight, _disableSelfView } = this.props;
const { _disableSelfView } = this.props;
if (!this._separateLocalThumbnail && !_disableSelfView && viewableItems[0]?.index === 0) {
// Skip the local thumbnail.
@@ -206,31 +205,13 @@ 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, fullyVisibleCount));
this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex));
}
/**

View File

@@ -44,7 +44,6 @@ import {
TOP_FILMSTRIP_HEIGHT,
TOUCH_DRAG_HANDLE_PADDING
} from '../../constants';
import { calculateFullyVisibleParticipantsCount } from '../../functions.any';
import {
getVerticalViewMaxWidth,
isFilmstripDisabled,
@@ -945,33 +944,10 @@ class Filmstrip extends PureComponent <IProps, IState> {
*/
_onListItemsRendered({ visibleStartIndex, visibleStopIndex }: {
visibleStartIndex: number; visibleStopIndex: number; }) {
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 { dispatch } = this.props;
const { startIndex, stopIndex } = this._calculateIndices(visibleStartIndex, visibleStopIndex);
dispatch(setVisibleRemoteParticipants(startIndex, stopIndex, fullyVisibleCount));
dispatch(setVisibleRemoteParticipants(startIndex, stopIndex));
}
/**

View File

@@ -1,5 +1,8 @@
import { IReduxState, IStore } from '../app/types';
import { getParticipantById, getVirtualScreenshareParticipantOwnerId } from '../base/participants/functions';
import {
getActiveSpeakersToBeDisplayed,
getVirtualScreenshareParticipantOwnerId
} from '../base/participants/functions';
import { setRemoteParticipants } from './actions';
import { isFilmstripScrollVisible } from './functions';
@@ -30,70 +33,44 @@ 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 = new Array<string>();
const { fullyVisibleRemoteParticipantsCount } = state['features/filmstrip'];
const speakers = getActiveSpeakersToBeDisplayed(state);
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,
...speakers,
...Array.from(speakers.keys()),
...Array.from(remoteParticipants.keys())
];
@@ -131,31 +108,3 @@ export function isTileViewModeDisabled(state: IReduxState) {
return tileView.disabled;
}
/**
* Calculates the count of fully visible participants, excluding any partially visible tiles.
* This respects the actual rendered items from the list component while accounting for
* container padding/gaps.
*
* @param {number} visibleStartIndex - The start index of visible items.
* @param {number} visibleEndIndex - The end index of visible items.
* @param {number} containerSize - The width or height of the filmstrip container.
* @param {number} itemSize - The width or height of each item including margin.
* @returns {number} - The count of fully visible participants (at least 1).
*/
export function calculateFullyVisibleParticipantsCount(
visibleStartIndex: number,
visibleEndIndex: number,
containerSize: number,
itemSize: number
): number {
// Current visible count from the list component (includes any partially visible tile)
const currentVisibleCount = visibleEndIndex - visibleStartIndex + 1;
// Theoretical max that can fit fully in the container
const maxFullyVisible = Math.floor(containerSize / itemSize);
// Fully visible count is the minimum of actual visible and max that can fit fully
// Ensure at least 1 if there are any visible items
return Math.max(1, Math.min(currentVisibleCount, maxFullyVisible));
}

View File

@@ -158,15 +158,6 @@ 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.
*
@@ -210,7 +201,6 @@ export interface IFilmstripState {
pinned?: boolean;
}>;
enabled: boolean;
fullyVisibleRemoteParticipantsCount: number;
horizontalViewDimensions: {
hasScroll?: boolean;
local?: IDimensions;
@@ -311,7 +301,7 @@ ReducerRegistry.register<IFilmstripState>(
}
};
case SET_VISIBLE_REMOTE_PARTICIPANTS: {
const { endIndex, startIndex, fullyVisibleCount } = action;
const { endIndex, startIndex } = action;
const { remoteParticipants } = state;
const visibleRemoteParticipants = new Set(remoteParticipants.slice(startIndex, endIndex + 1));
@@ -319,8 +309,7 @@ ReducerRegistry.register<IFilmstripState>(
...state,
visibleParticipantsStartIndex: startIndex,
visibleParticipantsEndIndex: endIndex,
visibleRemoteParticipants,
fullyVisibleRemoteParticipantsCount: fullyVisibleCount ?? visibleRemoteParticipants.size
visibleRemoteParticipants
};
}
case PARTICIPANT_LEFT: {

View File

@@ -2,6 +2,7 @@ import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
CONFERENCE_WILL_LEAVE,
SET_PASSWORD
} from '../base/conference/actionTypes';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
@@ -69,6 +70,7 @@ ReducerRegistry.register<ILobbyState>('features/lobby', (state = DEFAULT_STATE,
}
case CONFERENCE_JOINED:
case CONFERENCE_LEFT:
case CONFERENCE_WILL_LEAVE:
return {
...state,
isDisplayNameRequiredError: false,

View File

@@ -63,6 +63,7 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
const roomName = useSelector((state: IReduxState) => getConferenceName(state));
const roomNameEnabled = useSelector((state: IReduxState) => isRoomNameEnabled(state));
const { showHangUp: showHangUpLobby = true } = useSelector((state: IReduxState) => getLobbyConfig(state));
const { showHangUp: showHangUpPrejoin = true } = useSelector((state: IReduxState) => state['features/base/config'].prejoinConfig || {});
const { knocking } = useSelector((state: IReduxState) => state['features/lobby']);
const participantName = localParticipant?.name;
const [ displayName, setDisplayName ]
@@ -186,7 +187,7 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
<VideoMuteButton
styles = { styles.buttonStylesBorderless } />
{
knocking && showHangUpLobby
(knocking ? showHangUpLobby : showHangUpPrejoin)
&& <HangupButton
styles = { styles.buttonStylesBorderless } />
}

View File

@@ -7,7 +7,7 @@ import { JitsiRecordingConstants } from '../base/lib-jitsi-meet';
import { getSoundFileSrc } from '../base/media/functions';
import { getLocalParticipant, getRemoteParticipants } from '../base/participants/functions';
import { registerSound, unregisterSound } from '../base/sounds/actions';
import { isEmbedded, isEmbeddedFromSameDomain } from '../base/util/embedUtils';
import { isEmbedded } 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() || isEmbeddedFromSameDomain());
return LocalRecordingManager.isSupported() && !isEmbedded();
}
/**

View File

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

View File

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

View File

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

View File

@@ -38,8 +38,9 @@ export default class WebhookProxy {
this.ws.on('error', console.error);
this.ws.on('open', function open() {
this.ws.on('open', () => {
console.log('WebhookProxy connected');
this.logInfo('connected');
});
this.ws.on('message', (data: any) => {

View File

@@ -1,4 +1,4 @@
import { P1, P2, P3, P4, P5, P6, Participant } from './Participant';
import { P1, P2, P3, P4, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';
@@ -122,50 +122,6 @@ 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.
*
@@ -288,16 +244,10 @@ export async function checkForScreensharingTile(sharer: Participant, observer: P
}
/**
* Hangs up all participants (p1, p2, p3, p4, p5, and p6)
* Hangs up all participants (p1, p2, p3 and p4)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
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()));
return Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup(), ctx.p3?.hangup(), ctx.p4?.hangup() ]
.map(p => p ?? Promise.resolve()));
}

View File

@@ -7,16 +7,13 @@ import { IToken, ITokenOptions } from './token';
export type IContext = {
/**
* The up-to-six browser instances provided by the framework. These can be initialized using
* The up-to-four 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;
/**
@@ -42,7 +39,7 @@ export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6';
name: 'p1' | 'p2' | 'p3' | 'p4';
/** An optional token to use. */
token?: IToken;
};

View File

@@ -8,7 +8,6 @@ import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
setTestProperties(__filename, {
requireWebhookProxy: true,
retry: true,
useJaas: true,
usesBrowsers: [ 'p1', 'p2' ]
});
@@ -72,6 +71,8 @@ for (const asyncTranscriptions of asyncTranscriptionValues) {
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('toggleSubtitles');
@@ -80,18 +81,29 @@ for (const asyncTranscriptions of asyncTranscriptionValues) {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
// The p1 event can be triggered locally, resulting in the code below executing prematurely. Make sure
// transcription is indeed stopped on the backend before proceeding.
await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
it('set subtitles on and off', async () => {
// we need to clear results or the last one will be used, from the previous time subtitles were on
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
webhooksProxy.clearCache();
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
await checkReceivingChunks(p1, p2, webhooksProxy, asyncTranscriptions);
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
await p1.getIframeAPI().executeCommand('setSubtitles', false);
@@ -100,6 +112,13 @@ for (const asyncTranscriptions of asyncTranscriptionValues) {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
// The p1 event can be triggered locally, resulting in the code below executing prematurely. Make sure
// transcription is indeed stopped on the backend before proceeding.
await p2.driver.waitUntil(() => p2.getIframeAPI()
.getEventResult('transcribingStatusChanged'), {
timeout: 15000,
timeoutMsg: 'transcribingStatusChanged event not received by p1'
});
});
it('start/stop transcriptions via recording', async () => {

View File

@@ -1,20 +1,16 @@
import type { Participant } from '../../helpers/Participant';
import { setTestProperties } from '../../helpers/TestProperties';
import {
checkForScreensharingTile,
ensureSixParticipants,
ensureThreeParticipants,
hangupAllParticipants
} from '../../helpers/participants';
import { ensureThreeParticipants } from '../../helpers/participants';
import { muteAudioAndCheck } from '../helpers/mute';
setTestProperties(__filename, {
usesBrowsers: [ 'p1', 'p2', 'p3', 'p4', 'p5', 'p6' ]
usesBrowsers: [ 'p1', 'p2', 'p3' ]
});
describe('Active speaker', () => {
it('testActiveSpeaker', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
await muteAudioAndCheck(p1, p2);
@@ -34,306 +30,6 @@ 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();
});
});
@@ -399,81 +95,3 @@ async function assertOneDominantSpeaker(participant: Participant) {
expect(await participant.driver.$$(
'//span[not(contains(@class, "tile-view"))]//span[contains(@class,"dominant-speaker")]').length).toBe(1);
}
/**
* Wait for a participant to be detected as the dominant speaker.
*
* @param {Participant} observer - The participant observing the dominant speaker state.
* @param {string} participantId - The endpoint ID of the expected dominant speaker.
* @param {string} participantName - The name of the participant for logging.
*/
async function waitForDominantSpeaker(
observer: Participant,
participantId: string,
participantName: string
): Promise<void> {
await observer.driver.waitUntil(
async () => {
const state = await observer.execute(() => {
const participants = APP.store.getState()['features/base/participants'];
return participants.dominantSpeaker;
});
return state === participantId;
},
{
timeout: 10000,
timeoutMsg: `${participantName} (${participantId}) was not detected as dominant speaker within 10 seconds`
}
);
// Wait a bit more for filmstrip state to update after dominant speaker changes
await observer.driver.pause(1000);
}
/**
* Get the current filmstrip state from Redux.
*
* @param {Participant} participant - The participant to query.
* @returns {Promise<FilmstripState>} The filmstrip state.
*/
async function getFilmstripState(participant: Participant): Promise<{
dominantSpeaker: string | null;
remoteParticipants: string[];
visibleRemoteParticipants: string[];
}> {
return await participant.execute(() => {
const state = APP.store.getState();
const filmstrip = state['features/filmstrip'];
const participants = state['features/base/participants'];
return {
dominantSpeaker: participants.dominantSpeaker,
remoteParticipants: filmstrip.remoteParticipants,
visibleRemoteParticipants: Array.from(filmstrip.visibleRemoteParticipants)
};
});
}
/**
* Set display names for all 6 participants to create alphabetical ordering.
*/
async function setAlphabeticalDisplayNames(
p1: Participant,
p2: Participant,
p3: Participant,
p4: Participant,
p5: Participant,
p6: Participant
): Promise<void> {
await p1.setLocalDisplayName('Alice');
await p2.setLocalDisplayName('Bob');
await p3.setLocalDisplayName('Charlie');
await p4.setLocalDisplayName('Eve');
await p5.setLocalDisplayName('Frank');
await p6.setLocalDisplayName('Zoe');
// Wait for display names to propagate
await p1.driver.pause(2000);
}

View File

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