Compare commits

..

9 Commits

Author SHA1 Message Date
Robin
7d7a9ee847 Watch for a 'join' action to know when the call is connected
Previously we were watching for changes to the room state to know when you become connected to a call. However, the room state might not change if you had a stuck membership event prior to re-joining the call. It's going to be more reliable to watch for the 'join' action that Element Call sends, and use that to track the connection state.
2025-03-14 12:52:16 -04:00
Robin
5df083f009 Stop sending call notification events
Since I'd like Element Web to stop doing any more inspection than it needs to into the MatrixRTC session state, I suggest we move all responsibility for sending MatrixRTC events to Element Call.
2025-03-13 17:04:42 -04:00
Robin
991ce70209 Remove unused layout tracking code 2025-03-13 15:38:52 -04:00
Robin
60de81b824 Remove the unused 'preload' option 2025-03-13 15:38:52 -04:00
Florian Duros
20d8abf7c2 New room list: add primary filters (#29481)
* feat(room filter): add component for the primary filters

* feat(room filter): add filter component to room list view

* test(room filter): add tests to primary filters

* test: update snapshots

* test(e2e): update snapshots

* test(e2e): add tests for primary filters

* refactor: change aria-label of primary filters
2025-03-13 17:29:57 +00:00
Tulir Asokan
fda658182a Implement MSC4142: Remove unintentional intentional mentions in replies (#28209)
* Implement MSC4142: Remove unintentional intentional mentions in replies

* Fix comment
2025-03-13 16:00:54 +00:00
renovate[bot]
9bfea92b66 Update testcontainers-node monorepo to v10.19.0 (#29491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 15:46:27 +00:00
Michael Telatynski
962136d453 Avoid using /tmp/ for bind mounts and non-tmpfs binds (#29488)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-03-13 14:51:01 +00:00
Florian Duros
917d53a56f Add wrap props to flex component (#29480)
* feat(flex): add wrap props to flex component

* test: update snapshot
2025-03-13 13:32:48 +00:00
51 changed files with 811 additions and 856 deletions

View File

@@ -77,7 +77,7 @@ jobs:
--rm \
-e "ELEMENT_WEB_PORT=$ELEMENT_WEB_PORT" \
-dp "$ELEMENT_WEB_PORT:$ELEMENT_WEB_PORT" \
-v $(pwd)/modules:/tmp/element-web-modules \
-v $(pwd)/modules:/modules \
"$IMAGEID" \
)

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# Loads modules from `/tmp/element-web-modules` into config.json's `modules` field
# Loads modules from `/modules` into config.json's `modules` field
set -e
@@ -15,15 +15,15 @@ mkdir -p /tmp/element-web-config
cp /app/config*.json /tmp/element-web-config/
# If there are modules to be loaded
if [ -d "/tmp/element-web-modules" ]; then
cd /tmp/element-web-modules
if [ -d "/modules" ]; then
cd /modules
for MODULE in *
do
# If the module has a package.json, use its main field as the entrypoint
ENTRYPOINT="index.js"
if [ -f "/tmp/element-web-modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/tmp/element-web-modules/$MODULE/package.json")
if [ -f "/modules/$MODULE/package.json" ]; then
ENTRYPOINT=$(jq -r '.main' "/modules/$MODULE/package.json")
fi
entrypoint_log "Loading module $MODULE with entrypoint $ENTRYPOINT"

View File

@@ -22,7 +22,7 @@ server {
add_header Cache-Control "no-cache";
}
location /modules {
alias /tmp/element-web-modules;
alias /modules;
}
# redirect server error pages to the static page /50x.html
#

View File

@@ -67,7 +67,7 @@ image as root (`docker run --user 0`) or, better, change the port that nginx
listens on via the `ELEMENT_WEB_PORT` environment variable.
[Element Web Modules](https://github.com/element-hq/element-modules/tree/main/packages/element-web-module-api) can be dynamically loaded
by being made available (e.g. via bind mount) in a directory within `/tmp/element-web-modules/`.
by being made available (e.g. via bind mount) in a directory within `/modules/`.
The default entrypoint will be index.js in that directory but can be overridden if a package.json file is found with a `main` directive.
These modules will be presented in a `/modules` subdirectory within the webroot, and automatically added to the config.json `modules` field.
@@ -75,7 +75,7 @@ If you wish to use docker in read-only mode,
you should follow the [upstream instructions](https://hub.docker.com/_/nginx#:~:text=Running%20nginx%20in%20read%2Donly%20mode)
but additionally include the following directories:
- /tmp/element-web-config/
- /tmp/
- /etc/nginx/conf.d/
The behaviour of the docker image can be customised via the following

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { expect, test } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Room list filters and sort", () => {
test.use({
displayName: "Alice",
botCreateOpts: {
displayName: "BotBob",
autoAcceptInvites: true,
},
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
function getPrimaryFilters(page: Page) {
return page.getByRole("listbox", { name: "Room list filters" });
}
test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
await app.client.createRoom({ name: "empty room" });
const unReadDmId = await bot.createRoom({
name: "unread dm",
invite: [user.userId],
is_direct: true,
});
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
const unReadRoomId = await app.client.createRoom({ name: "unread room" });
await app.client.inviteUser(unReadRoomId, bot.credentials.userId);
await bot.joinRoom(unReadRoomId);
await bot.sendMessage(unReadRoomId, "I am a robot. Beep.");
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId);
});
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
const allFilters = await primaryFilters.locator("option").all();
for (const filter of allFilters) {
expect(await filter.getAttribute("aria-selected")).toBe("false");
}
await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C
Copyright 2017-2019 New Vector Ltd
Copyright 2017 Vector Creations Ltd
@@ -217,7 +217,7 @@ textarea {
}
input[type="text"]:focus,
:not(.mx_ChangePasswordForm input) > input[type="password"],
input[type="password"]:focus,
textarea:focus {
outline: none;
box-shadow: none;
@@ -592,7 +592,6 @@ legend {
*/
.mx_Dialog
button:not(
.mx_ChangePasswordForm button,
.mx_EncryptionUserSettingsTab button,
.mx_UserProfileSettings button,
.mx_ShareDialog button,
@@ -621,7 +620,6 @@ legend {
.mx_Dialog
button:not(
.mx_ChangePasswordForm button,
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,
@@ -636,7 +634,6 @@ legend {
.mx_Dialog
button:not(
.mx_ChangePasswordForm button,
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,
@@ -656,7 +653,6 @@ legend {
.mx_Dialog input[type="submit"].mx_Dialog_primary,
.mx_Dialog_buttons
button:not(
.mx_ChangePasswordForm button,
.mx_Dialog_nonDialogButton,
.mx_AccessibleButton,
.mx_UserProfileSettings button,
@@ -676,7 +672,6 @@ legend {
.mx_Dialog input[type="submit"].danger,
.mx_Dialog_buttons
button.danger:not(
.mx_ChangePasswordForm button,
.mx_Dialog_nonDialogButton,
.mx_AccessibleButton,
.mx_UserProfileSettings button,
@@ -699,7 +694,6 @@ legend {
.mx_Dialog
button:not(
.mx_ChangePasswordForm button,
.mx_Dialog_nonDialogButton,
[class|="maplibregl"],
.mx_AccessibleButton,

View File

@@ -273,6 +273,7 @@
@import "./views/rooms/RoomListPanel/_RoomListCell.pcss";
@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/_AppsDrawer.pcss";
@import "./views/rooms/_Autocomplete.pcss";

View File

@@ -12,4 +12,5 @@ Please see LICENSE files in the repository root for full details.
align-items: var(--mx-flex-align, unset);
justify-content: var(--mx-flex-justify, unset);
gap: var(--mx-flex-gap, unset);
flex-wrap: var(--mx-flex-wrap, unset);
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.mx_RoomListPrimaryFilters {
margin: unset;
list-style-type: none;
padding: var(--cpd-space-2x) var(--cpd-space-3x);
}

View File

@@ -39,6 +39,11 @@ type FlexProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any
* @default start
*/
justify?: "start" | "center" | "end" | "space-between";
/**
* The wrapping of the flex children
* @default nowrap
*/
wrap?: "wrap" | "nowrap" | "wrap-reverse";
/**
* The spacing between the flex children, expressed with the CSS unit
* @default 0
@@ -60,6 +65,7 @@ export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstruct
align = "start",
justify = "start",
gap = "0",
wrap = "nowrap",
className,
children,
...props
@@ -71,8 +77,9 @@ export function Flex<T extends keyof JSX.IntrinsicElements | JSXElementConstruct
"--mx-flex-align": align,
"--mx-flex-justify": justify,
"--mx-flex-gap": gap,
"--mx-flex-wrap": wrap,
}),
[align, direction, display, gap, justify],
[align, direction, display, gap, justify, wrap],
);
return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children);

View File

@@ -6,24 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type RefCallback, type RefObject, useCallback, useMemo, useState } from "react";
import React, { type ComponentProps, PureComponent, type RefCallback, type RefObject } from "react";
import classNames from "classnames";
import type { Score, ZxcvbnResult } from "@zxcvbn-ts/core";
import type { ZxcvbnResult } from "@zxcvbn-ts/core";
import SdkConfig from "../../../SdkConfig";
import withValidation, { type IValidationResult } from "../elements/Validation";
import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation";
import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { type IInputProps } from "../elements/Field";
import Field, { type IInputProps } from "../elements/Field";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Field, Label, PasswordInput, Progress } from "@vector-im/compound-web";
const SCORE_TINT: Record<Score, "red" | "orange" | "lime" | "green"> ={
"0": "red",
"1": "red",
"2": "orange",
"3": "lime",
"4": "green"
};
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
autoFocus?: boolean;
@@ -31,44 +22,43 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
className?: string;
minScore: 0 | 1 | 2 | 3 | 4;
value: string;
fieldRef?: RefCallback<HTMLInputElement> | RefObject<HTMLInputElement>;
fieldRef?: RefCallback<Field> | RefObject<Field>;
// Additional strings such as a username used to catch bad passwords
userInputs?: string[];
label: TranslationKey;
labelEnterPassword?: TranslationKey;
labelStrongPassword?: TranslationKey;
labelAllowedButUnsafe?: TranslationKey;
// tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
labelEnterPassword: TranslationKey;
labelStrongPassword: TranslationKey;
labelAllowedButUnsafe: TranslationKey;
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}
const DEFAULT_PROPS = {
label: _td("common|password"),
labelEnterPassword: _td("auth|password_field_label"),
labelStrongPassword: _td("auth|password_field_strong_label"),
labelAllowedButUnsafe: _td("auth|password_field_weak_label"),
};
class PassphraseField extends PureComponent<IProps> {
public static defaultProps = {
label: _td("common|password"),
labelEnterPassword: _td("auth|password_field_label"),
labelStrongPassword: _td("auth|password_field_strong_label"),
labelAllowedButUnsafe: _td("auth|password_field_weak_label"),
};
const PassphraseField: React.FC<IProps> = (props) => {
const { labelEnterPassword, userInputs, minScore, label, labelStrongPassword, labelAllowedButUnsafe, className, id, fieldRef, autoFocus, onChange, onValidate} = {...DEFAULT_PROPS, ...props};
const validateFn = useMemo(() => withValidation<{}, ZxcvbnResult | null>({
public readonly validate = withValidation<this, ZxcvbnResult | null>({
description: function (complexity) {
const score = complexity ? complexity.score : 0;
return <Progress tint={SCORE_TINT[score]} size="sm" value={score} max={4} />
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
},
deriveData: async ({ value }): Promise<ZxcvbnResult | null> => {
if (!value) return null;
const { scorePassword } = await import("../../../utils/PasswordScorer");
return scorePassword(MatrixClientPeg.get(), value, userInputs);
return scorePassword(MatrixClientPeg.get(), value, this.props.userInputs);
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(labelEnterPassword),
invalid: () => _t(this.props.labelEnterPassword),
},
{
key: "complexity",
@@ -76,7 +66,7 @@ const PassphraseField: React.FC<IProps> = (props) => {
if (!value || !complexity) {
return false;
}
const safe = complexity.score >= minScore;
const safe = complexity.score >= this.props.minScore;
const allowUnsafe = SdkConfig.get("dangerously_allow_unsafe_and_insecure_passwords");
return allowUnsafe || safe;
},
@@ -84,10 +74,10 @@ const PassphraseField: React.FC<IProps> = (props) => {
// Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe.
if (complexity && complexity.score >= minScore) {
return _t(labelStrongPassword);
if (complexity && complexity.score >= this.props.minScore) {
return _t(this.props.labelStrongPassword);
}
return _t(labelAllowedButUnsafe);
return _t(this.props.labelAllowedButUnsafe);
},
invalid: function (complexity) {
if (!complexity) {
@@ -99,26 +89,33 @@ const PassphraseField: React.FC<IProps> = (props) => {
},
],
memoize: true,
}), [labelEnterPassword, userInputs, minScore, labelStrongPassword, labelAllowedButUnsafe]);
const [feedback, setFeedback]= useState<string|JSX.Element>();
});
const onInputChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>((ev) => {
onChange(ev);
validateFn({
value: ev.target.value,
focused: true,
}).then((v) => {
setFeedback(v.feedback);
onValidate?.(v);
});
}, [validateFn]);
public onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};
return <Field id={id} name="password" className={classNames("mx_PassphraseField", className)}>
<Label>{_t(label)}</Label>
<PasswordInput ref={fieldRef} autoFocus={autoFocus} onChange={onInputChange} />
{feedback}
</Field>
public render(): React.ReactNode {
return (
<Field
id={this.props.id}
autoFocus={this.props.autoFocus}
className={classNames("mx_PassphraseField", this.props.className)}
ref={this.props.fieldRef}
type="password"
autoComplete="new-password"
label={_t(this.props.label)}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
tooltipAlignment={this.props.tooltipAlignment}
/>
);
}
}
export default PassphraseField;

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { ChatFilter } from "@vector-im/compound-web";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
interface RoomListPrimaryFiltersProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The primary filters for the room list
*/
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
return (
<Flex
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
className="mx_RoomListPrimaryFilters"
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
>
{vm.primaryFilters.map((filter) => (
<li role="option" aria-selected={filter.active} key={filter.name}>
<ChatFilter selected={filter.active} onClick={filter.toggle}>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
);
}

View File

@@ -9,12 +9,17 @@ import React, { type JSX } from "react";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "./RoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
/**
* Host the room list and the (future) room filters
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
// Room filters will be added soon
return <RoomList vm={vm} />;
return (
<>
<RoomListPrimaryFilters vm={vm} />
<RoomList vm={vm} />
</>
);
}

View File

@@ -95,16 +95,9 @@ export function attachMentions(
const userMentions = new Set<string>();
let roomMention = false;
// If there's a reply, initialize the mentioned users as the sender of that
// event + any mentioned users in that event.
// If there's a reply, initialize the mentioned users as the sender of that event.
if (replyToEvent) {
userMentions.add(replyToEvent.sender!.userId);
// TODO What do we do if the reply event *doeesn't* have this property?
// Try to fish out replies from the contents?
const userIds = replyToEvent.getContent()["m.mentions"]?.user_ids;
if (Array.isArray(userIds)) {
userIds.forEach((userId) => userMentions.add(userId));
}
}
// If user provided content is available, check to see if any users are mentioned.

View File

@@ -9,12 +9,16 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import Field from "../elements/Field";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton, { type AccessibleButtonKind } from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation";
import { UserFriendlyError, _t, _td } from "../../../languageHandler";
import { PASSWORD_MIN_SCORE } from "../auth/RegistrationForm";
import { Root, Field as CpdField, PasswordInput, Label, InlineSpinner, HelpMessage, Button } from "@vector-im/compound-web";
import Modal from "../../../Modal";
import PassphraseField from "../auth/PassphraseField";
import { PASSWORD_MIN_SCORE } from "../auth/RegistrationForm";
import SetEmailDialog from "../dialogs/SetEmailDialog";
const FIELD_OLD_PASSWORD = "field_old_password";
const FIELD_NEW_PASSWORD = "field_new_password";
@@ -30,11 +34,19 @@ enum Phase {
interface IProps {
onFinished: (outcome: { didSetEmail?: boolean }) => void;
onError: (error: Error) => void;
rowClassName?: string;
buttonClassName?: string;
buttonKind?: AccessibleButtonKind;
buttonLabel?: string;
confirm?: boolean;
// Whether to autoFocus the new password input
autoFocusNewPasswordInput?: boolean;
className?: string;
shouldAskForEmail?: boolean;
}
interface IState {
fieldValid: Partial<Record<FieldType, IValidationResult>>;
fieldValid: Partial<Record<FieldType, boolean>>;
phase: Phase;
oldPassword: string;
newPassword: string;
@@ -42,13 +54,15 @@ interface IState {
}
export default class ChangePassword extends React.Component<IProps, IState> {
private [FIELD_OLD_PASSWORD]: HTMLInputElement | null = null;
private [FIELD_NEW_PASSWORD]: HTMLInputElement | null = null;
private [FIELD_NEW_PASSWORD_CONFIRM]: HTMLInputElement | null = null;
private [FIELD_OLD_PASSWORD]: Field | null = null;
private [FIELD_NEW_PASSWORD]: Field | null = null;
private [FIELD_NEW_PASSWORD_CONFIRM]: Field | null = null;
public static defaultProps: Partial<IProps> = {
onFinished() {},
onError() {},
confirm: true,
};
public constructor(props: IProps) {
@@ -86,7 +100,15 @@ export default class ChangePassword extends React.Component<IProps, IState> {
cli.setPassword(authDict, newPassword, false)
.then(
() => {
this.props.onFinished({});
if (this.props.shouldAskForEmail) {
return this.optionallySetEmail().then((confirmed) => {
this.props.onFinished({
didSetEmail: confirmed,
});
});
} else {
this.props.onFinished({});
}
},
(err) => {
if (err instanceof Error) {
@@ -127,9 +149,17 @@ export default class ChangePassword extends React.Component<IProps, IState> {
}
}
private markFieldValid(fieldID: FieldType, result: IValidationResult): void {
private optionallySetEmail(): Promise<boolean> {
// Ask for an email otherwise the user has no way to reset their password
const modal = Modal.createDialog(SetEmailDialog, {
title: _t("auth|set_email_prompt"),
});
return modal.finished.then(([confirmed]) => !!confirmed);
}
private markFieldValid(fieldID: FieldType, valid?: boolean): void {
const { fieldValid } = this.state;
fieldValid[fieldID] = result;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
@@ -139,16 +169,11 @@ export default class ChangePassword extends React.Component<IProps, IState> {
this.setState({
oldPassword: ev.target.value,
});
this.onOldPasswordValidate({
value: ev.target.value,
focused: true,
allowEmpty: true,
});
};
private onOldPasswordValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validateOldPasswordRules(fieldState);
this.markFieldValid(FIELD_OLD_PASSWORD, result);
this.markFieldValid(FIELD_OLD_PASSWORD, result.valid);
return result;
};
@@ -169,24 +194,18 @@ export default class ChangePassword extends React.Component<IProps, IState> {
};
private onNewPasswordValidate = (result: IValidationResult): void => {
this.markFieldValid(FIELD_NEW_PASSWORD, result);
this.markFieldValid(FIELD_NEW_PASSWORD, result.valid);
};
private onChangeNewPasswordConfirm = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPasswordConfirm: ev.target.value,
});
this.onNewPasswordConfirmValidate({
value: ev.target.value,
focused: true,
allowEmpty: true,
});
};
private onNewPasswordConfirmValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid);
return result;
};
@@ -247,25 +266,26 @@ export default class ChangePassword extends React.Component<IProps, IState> {
activeElement.blur();
}
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
await this.onOldPasswordValidate({
value: this[FIELD_OLD_PASSWORD]?.value ?? null,
allowEmpty: false,
focused: true,
});
await this.onNewPasswordConfirmValidate({
value: this[FIELD_NEW_PASSWORD_CONFIRM]?.value ?? null,
allowEmpty: false,
focused: true,
});
const fieldIDsInDisplayOrder: FieldType[] = [
FIELD_OLD_PASSWORD,
FIELD_NEW_PASSWORD,
FIELD_NEW_PASSWORD_CONFIRM,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise<void>((resolve) => this.setState({}, resolve));
@@ -283,16 +303,15 @@ export default class ChangePassword extends React.Component<IProps, IState> {
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
// TODO: HMM
// invalidField.validate({ allowEmpty: false, focused: true });
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid(): boolean {
return Object.values(this.state.fieldValid).map(v => v.valid).every(Boolean);
return Object.values(this.state.fieldValid).every(Boolean);
}
private findFirstInvalidField(fieldIDs: FieldType[]): HTMLInputElement | null {
private findFirstInvalidField(fieldIDs: FieldType[]): Field | null {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
@@ -302,56 +321,60 @@ export default class ChangePassword extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const { fieldValid, phase } = this.state;
const rowClassName = this.props.rowClassName;
const buttonClassName = this.props.buttonClassName;
switch (phase) {
switch (this.state.phase) {
case Phase.Edit:
return (
<Root className={"mx_ChangePasswordForm"} onSubmit={this.onClickChange}>
<CpdField name={FIELD_OLD_PASSWORD}>
<Label>
{_t("auth|change_password_current_label")}
</Label>
<PasswordInput ref={(field) => (this[FIELD_OLD_PASSWORD] = field)} data-invalid={fieldValid[FIELD_OLD_PASSWORD]?.valid === false ? true : undefined} value={this.state.oldPassword} onChange={this.onChangeOldPassword} />
{fieldValid[FIELD_OLD_PASSWORD]?.feedback && <HelpMessage>
{fieldValid[FIELD_OLD_PASSWORD]?.feedback}
</HelpMessage>}
</CpdField>
{ /* This is a compound field. */}
<PassphraseField
fieldRef={(field) => (this[FIELD_NEW_PASSWORD] = field)}
type="password"
label={_td("auth|change_password_new_label")}
minScore={PASSWORD_MIN_SCORE}
value={this.state.newPassword}
onChange={this.onChangeNewPassword}
onValidate={this.onNewPasswordValidate}
autoComplete="new-password"
/>
<CpdField name={FIELD_NEW_PASSWORD_CONFIRM}>
<Label>
{_t("auth|change_password_confirm_label")}
</Label>
<PasswordInput autoComplete="new-password" ref={(field) => (this[FIELD_NEW_PASSWORD_CONFIRM] = field)} data-invalid={fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.valid === false ? true : undefined} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} />
{fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback && <HelpMessage>
{fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback}
</HelpMessage>}
</CpdField>
<Button
disabled={!this.allFieldsValid()}
style={{width: "fit-content"}}
<form className={this.props.className} onSubmit={this.onClickChange}>
<div className={rowClassName}>
<Field
ref={(field) => (this[FIELD_OLD_PASSWORD] = field)}
type="password"
label={_t("auth|change_password_current_label")}
value={this.state.oldPassword}
onChange={this.onChangeOldPassword}
onValidate={this.onOldPasswordValidate}
/>
</div>
<div className={rowClassName}>
<PassphraseField
fieldRef={(field) => (this[FIELD_NEW_PASSWORD] = field)}
type="password"
label={_td("auth|change_password_new_label")}
minScore={PASSWORD_MIN_SCORE}
value={this.state.newPassword}
autoFocus={this.props.autoFocusNewPasswordInput}
onChange={this.onChangeNewPassword}
onValidate={this.onNewPasswordValidate}
autoComplete="new-password"
/>
</div>
<div className={rowClassName}>
<Field
ref={(field) => (this[FIELD_NEW_PASSWORD_CONFIRM] = field)}
type="password"
label={_t("auth|change_password_confirm_label")}
value={this.state.newPasswordConfirm}
onChange={this.onChangeNewPasswordConfirm}
onValidate={this.onNewPasswordConfirmValidate}
autoComplete="new-password"
/>
</div>
<AccessibleButton
className={buttonClassName}
kind={this.props.buttonKind}
onClick={this.onClickChange}
kind="primary"
size="sm"
>
{this.props.buttonLabel || _t("auth|change_password_action")}
</Button>
</Root>
</AccessibleButton>
</form>
);
case Phase.Uploading:
return (
<div className="mx_Dialog_content">
<InlineSpinner />
<Spinner />
</div>
);
}

View File

@@ -2114,6 +2114,7 @@
"list_title": "Room list",
"notification_options": "Notification options",
"open_space_menu": "Open space menu",
"primary_filters": "Room list filters",
"redacting_messages_status": {
"one": "Currently removing messages in %(count)s room",
"other": "Currently removing messages in %(count)s rooms"

View File

@@ -23,18 +23,15 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "matrix-widget-api";
import {
MatrixRTCSession,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type CallMembership,
MatrixRTCSessionManagerEvents,
type ICallNotifyContent,
} from "matrix-js-sdk/src/matrixrtc";
import type EventEmitter from "events";
import type { IApp } from "../stores/WidgetStore";
import SdkConfig, { DEFAULTS } from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType";
@@ -45,7 +42,6 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
import { getCurrentLanguage } from "../languageHandler";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
import { isVideoRoom } from "../utils/video-rooms";
import { FontWatcher } from "../settings/watchers/FontWatcher";
import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
@@ -85,15 +81,9 @@ export enum ConnectionState {
export const isConnected = (state: ConnectionState): boolean =>
state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
export enum Layout {
Tile = "tile",
Spotlight = "spotlight",
}
export enum CallEvent {
ConnectionState = "connection_state",
Participants = "participants",
Layout = "layout",
Close = "close",
Destroy = "destroy",
}
@@ -104,7 +94,6 @@ interface CallEventHandlerMap {
participants: Map<RoomMember, Set<string>>,
prevParticipants: Map<RoomMember, Set<string>>,
) => void;
[CallEvent.Layout]: (layout: Layout) => void;
[CallEvent.Close]: () => void;
[CallEvent.Destroy]: () => void;
}
@@ -202,18 +191,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/
public abstract clean(): Promise<void>;
/**
* Contacts the widget to connect to the call or prompt the user to connect to the call.
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
* null to start muted.
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
* null to start muted.
*/
protected abstract performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void>;
/**
* Contacts the widget to disconnect from the call.
*/
@@ -221,28 +198,10 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
/**
* Starts the communication between the widget and the call.
* The call then waits for the necessary requirements to actually perform the connection
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
* It uses the media devices set in MediaDeviceHandler.
* The widget associated with the call must be active
* for this to succeed.
* The widget associated with the call must be active for this to succeed.
* Only call this if the call state is: ConnectionState.Disconnected.
*/
public async start(): Promise<void> {
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
(await MediaDeviceHandler.getDevices())!;
let audioInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithAudioMuted) {
const deviceId = MediaDeviceHandler.getAudioInput();
audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
}
let videoInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithVideoMuted) {
const deviceId = MediaDeviceHandler.getVideoInput();
videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
}
const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null;
if (!this.messaging) {
@@ -263,13 +222,23 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
}
}
await this.performConnection(audioInput, videoInput);
}
protected setConnected(): void {
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected;
}
/**
* Manually marks the call as disconnected.
*/
protected setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Disconnects the user from the call.
*/
@@ -282,15 +251,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
this.close();
}
/**
* Manually marks the call as disconnected.
*/
public setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Stops further communication with the widget and tells the UI to close.
*/
@@ -476,66 +436,10 @@ export class JitsiCall extends Call {
});
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
const listener = (uid: string): void => {
if (uid === this.widgetUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = (): void => {
cleanup();
resolve();
};
const cleanup = (): void => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(CallEvent.ConnectionState, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(CallEvent.ConnectionState, done);
});
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we need
// to add the hangup listener now rather than later
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
try {
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (this.messaging!.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
}
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
}
@@ -558,18 +462,17 @@ export class JitsiCall extends Call {
}
}
public setDisconnected(): void {
// During tests this.messaging can be undefined
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
super.setDisconnected();
super.close();
}
public destroy(): void {
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
@@ -621,27 +524,21 @@ export class JitsiCall extends Call {
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (this.connectionState === ConnectionState.Disconnected) {
await waitForEvent(this, CallEvent.ConnectionState);
}
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
this.close();
// In video rooms we immediately want to restart the call after hangup
// The lobby will be shown again and it connects to all signals from Jitsi.
if (isVideoRoom(this.room)) {
this.start();
}
if (!isVideoRoom(this.room)) this.close();
};
}
@@ -658,14 +555,6 @@ export class ElementCall extends Call {
private settingsStoreCallEncryptionWatcher?: string;
private terminationTimer?: number;
private _layout = Layout.Tile;
public get layout(): Layout {
return this._layout;
}
protected set layout(value: Layout) {
this._layout = value;
this.emit(CallEvent.Layout, value);
}
public get presented(): boolean {
return super.presented;
@@ -688,7 +577,6 @@ export class ElementCall extends Call {
const params = new URLSearchParams({
embed: "true", // We're embedding EC within another application
// Template variables are used, so that this can be configured using the widget data.
preload: "$preload", // We want it to load in the background.
skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own.
returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms)
perParticipantE2EE: "$perParticipantE2EE",
@@ -728,17 +616,13 @@ export class ElementCall extends Call {
}
// Creates a new widget if there isn't any widget of typ Call in this room.
// Defaults for creating a new widget are: skipLobby = false, preload = false
// Defaults for creating a new widget are: skipLobby = false
// When there is already a widget the current widget configuration will be used or can be overwritten
// by passing the according parameters (skipLobby, preload).
//
// `preload` is deprecated. We used it for optimizing EC by using a custom EW call lobby and preloading the iframe.
// now it should always be false.
// by passing the according parameters (skipLobby).
private static createOrGetCallWidget(
roomId: string,
client: MatrixClient,
skipLobby: boolean | undefined,
preload: boolean | undefined,
returnToLobby: boolean | undefined,
): IApp {
const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type));
@@ -749,9 +633,6 @@ export class ElementCall extends Call {
if (skipLobby !== undefined) {
overwrites.skipLobby = skipLobby;
}
if (preload !== undefined) {
overwrites.preload = preload;
}
if (returnToLobby !== undefined) {
overwrites.returnToLobby = returnToLobby;
}
@@ -776,7 +657,6 @@ export class ElementCall extends Call {
{},
{
skipLobby: skipLobby ?? false,
preload: preload ?? false,
returnToLobby: returnToLobby ?? false,
},
),
@@ -842,7 +722,6 @@ export class ElementCall extends Call {
room.roomId,
room.client,
undefined,
undefined,
isVideoRoom(room),
);
return new ElementCall(session, availableOrCreatedWidget, room.client);
@@ -852,99 +731,41 @@ export class ElementCall extends Call {
}
public static create(room: Room, skipLobby = false): void {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room));
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
}
protected async sendCallNotify(): Promise<void> {
const room = this.room;
const existingOtherRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
// filter all memberships where the application is m.call and the call_id is ""
(m) => {
const isRoomCallMember = m.application === "m.call" && m.callId === "";
const isThisDevice = m.deviceId === this.client.deviceId;
return isRoomCallMember && !isThisDevice;
},
);
const memberCount = getJoinedNonFunctionalMembers(room).length;
if (!isVideoRoom(room) && existingOtherRoomCallMembers.length === 0) {
// send ringing event
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": memberCount == 2 ? "ring" : "notify",
"call_id": "",
};
await room.client.sendEvent(room.roomId, EventType.CallNotify, content);
}
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// The JoinCall action is only send if the widget is waiting for it.
if (this.widget.data?.preload) {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
}
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
// - set state to connecting
// - send call notify
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
if (session) {
await waitForEvent(
session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
newMemberships.some((m) => m.sender === this.client.getUserId()),
false, // allow user to wait as long as they want (no timeout)
);
} else {
await waitForEvent(
this.client.matrixRTC,
MatrixRTCSessionManagerEvents.SessionStarted,
(roomId: string, session: MatrixRTCSession) =>
this.session.callId === session.callId && roomId === this.roomId,
false, // allow user to wait as long as they want (no timeout)
);
}
this.sendCallNotify();
}
protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
await waitForEvent(
this.session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
!newMemberships.some((m) => m.sender === this.client.getUserId()),
);
await Promise.all([request, response]);
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected(): void {
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
super.setDisconnected();
super.close();
}
public destroy(): void {
@@ -966,15 +787,6 @@ export class ElementCall extends Call {
if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy();
};
/**
* Sets the call's layout.
* @param layout The layout to switch to.
*/
public async setLayout(layout: Layout): Promise<void> {
const action = layout === Layout.Tile ? ElementWidgetActions.TileLayout : ElementWidgetActions.SpotlightLayout;
await this.messaging!.transport.send(action, {});
}
private readonly onMembershipChanged = (): void => this.updateParticipants();
private updateParticipants(): void {
@@ -1000,15 +812,20 @@ export class ElementCall extends Call {
this.messaging!.transport.reply(ev.detail, {}); // ack
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
// In video rooms we immediately want to reconnect after hangup
// This starts the lobby again and connects to all signals from EC.
if (isVideoRoom(this.room)) {
this.start();
}
};
private readonly onClose = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
@@ -1018,18 +835,6 @@ export class ElementCall extends Call {
this.close();
};
private readonly onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault();
this.layout = Layout.Tile;
this.messaging!.transport.reply(ev.detail, {}); // ack
};
private readonly onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault();
this.layout = Layout.Spotlight;
this.messaging!.transport.reply(ev.detail, {}); // ack
};
public clean(): Promise<void> {
return Promise.resolve();
}

View File

@@ -79,7 +79,6 @@ export class MockedCall extends Call {
// No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {}
// Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}
public destroy() {

View File

@@ -99,7 +99,7 @@ exports[`<UnsupportedBrowserView /> should match snapshot 1`] = `
</p>
<div
class="mx_Flex mx_ErrorView_buttons"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
@@ -140,7 +140,7 @@ exports[`<UnsupportedBrowserView /> should match snapshot 1`] = `
</h2>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
@@ -210,7 +210,7 @@ exports[`<UnsupportedBrowserView /> should match snapshot 1`] = `
</h2>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<a
href="https://apps.apple.com/app/vector/id1083446067"

View File

@@ -52,7 +52,7 @@ exports[`FilePanel renders empty state 1`] = `
>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"

View File

@@ -7,7 +7,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -222,7 +222,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -522,7 +522,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -899,7 +899,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -1284,7 +1284,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -1492,7 +1492,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"
@@ -1873,7 +1873,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"

View File

@@ -43,7 +43,7 @@ exports[`<MasUnlockCrossSigningAuthEntry/> should render 1`] = `
</p>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_Dialog_nonDialogButton _has-icon_vczzf_57"

View File

@@ -151,7 +151,7 @@ describe("CallEvent", () => {
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View File

@@ -71,7 +71,7 @@ exports[`<ExtensionsCard /> should render empty state 1`] = `
</button>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"

View File

@@ -50,7 +50,7 @@ exports[`<PinnedMessagesCard /> should show the empty state when there are no pi
>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"

View File

@@ -69,7 +69,7 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
/>
<section
class="mx_Flex mx_RoomSummaryCard_badges"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8"
@@ -91,7 +91,7 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
</section>
<section
class="mx_Flex mx_RoomSummaryCard_topic"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_Box mx_RoomSummaryCard_topic_container mx_Box--flex"
@@ -724,7 +724,7 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
/>
<section
class="mx_Flex mx_RoomSummaryCard_badges"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8"
@@ -746,7 +746,7 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
</section>
<section
class="mx_Flex mx_RoomSummaryCard_topic"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_Box mx_Box--flex"
@@ -1342,7 +1342,7 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
/>
<section
class="mx_Flex mx_RoomSummaryCard_badges"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8"
@@ -1364,7 +1364,7 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
</section>
<section
class="mx_Flex mx_RoomSummaryCard_topic"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_Box mx_RoomSummaryCard_topic_container mx_Box--flex"

View File

@@ -77,7 +77,7 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
@@ -85,7 +85,7 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
@@ -113,7 +113,7 @@ exports[`<UserInfo /> with crypto enabled renders <BasicUserInfo /> 1`] = `
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
@@ -363,7 +363,7 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
@@ -371,7 +371,7 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
@@ -399,7 +399,7 @@ exports[`<UserInfo /> with crypto enabled should render a deactivate button for
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
class="_icon_11k6c_18"
@@ -653,7 +653,7 @@ exports[`<UserInfoHeader /> renders verification unavailable message 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
@@ -661,7 +661,7 @@ exports[`<UserInfoHeader /> renders verification unavailable message 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
@@ -689,7 +689,7 @@ exports[`<UserInfoHeader /> renders verification unavailable message 1`] = `
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<p
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 mx_UserInfo_verification_unavailable"
@@ -735,7 +735,7 @@ exports[`<UserInfoHeader /> renders verified badge when user is verified 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
@@ -743,7 +743,7 @@ exports[`<UserInfoHeader /> renders verified badge when user is verified 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
@@ -771,7 +771,7 @@ exports[`<UserInfoHeader /> renders verified badge when user is verified 1`] = `
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41 _badge_1t12g_8 mx_UserInfo_verified_badge"
@@ -832,7 +832,7 @@ exports[`<UserInfoHeader /> renders verify button 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
@@ -840,7 +840,7 @@ exports[`<UserInfoHeader /> renders verify button 1`] = `
>
<div
class="mx_Flex mx_UserInfo_profile_name"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row-reverse; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
@user:example.com
</div>
@@ -868,7 +868,7 @@ exports[`<UserInfoHeader /> renders verify button 1`] = `
</div>
<div
class="mx_Flex mx_UserInfo_verification"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_UserInfo_container_verifyButton"

View File

@@ -432,8 +432,6 @@ describe("<EditMessageComposer/>", () => {
user_ids: [
// sender of event we replied to
originalEvent.getSender()!,
// mentions from this event
"@bob:server.org",
],
},
},

View File

@@ -4,7 +4,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<DocumentFragment>
<header
class="mx_Flex mx_RoomHeader light-panel"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<button
aria-label="Open room settings"

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type RoomListViewState } from "../../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { SecondaryFilters } from "../../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
import { RoomListPrimaryFilters } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListPrimaryFilters";
describe("<RoomListPrimaryFilters />", () => {
let vm: RoomListViewState;
beforeEach(() => {
vm = {
rooms: [],
openRoom: jest.fn(),
primaryFilters: [
{ name: "People", active: false, toggle: jest.fn() },
{ name: "Rooms", active: true, toggle: jest.fn() },
],
activateSecondaryFilter: () => {},
activeSecondaryFilter: SecondaryFilters.AllActivity,
};
});
it("should render primary filters", async () => {
const user = userEvent.setup();
const { asFragment } = render(<RoomListPrimaryFilters vm={vm} />);
expect(screen.getByRole("option", { name: "People" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Rooms" })).toHaveAttribute("aria-selected", "true");
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "People" }));
expect(vm.primaryFilters[0].toggle).toHaveBeenCalled();
});
});

View File

@@ -31,7 +31,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -58,7 +58,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room0"
@@ -77,7 +77,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -104,7 +104,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room1"
@@ -123,7 +123,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -150,7 +150,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room2"
@@ -169,7 +169,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -196,7 +196,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room3"
@@ -215,7 +215,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -242,7 +242,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room4"
@@ -261,7 +261,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -288,7 +288,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room5"
@@ -307,7 +307,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -334,7 +334,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room6"
@@ -353,7 +353,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -380,7 +380,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room7"
@@ -399,7 +399,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -426,7 +426,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room8"
@@ -445,7 +445,7 @@ exports[`<RoomList /> should render a room list 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -472,7 +472,7 @@ exports[`<RoomList /> should render a room list 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room9"

View File

@@ -9,7 +9,7 @@ exports[`<RoomListCell /> should render a room cell 1`] = `
>
<div
class="mx_Flex mx_RoomListCell_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<div
class="mx_DecoratedRoomAvatar"
@@ -36,7 +36,7 @@ exports[`<RoomListCell /> should render a room cell 1`] = `
</div>
<div
class="mx_Flex mx_RoomListCell_content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
title="room1"

View File

@@ -6,11 +6,11 @@ exports[`<RoomListHeaderView /> compose menu should display the compose menu 1`]
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
@@ -93,11 +93,11 @@ exports[`<RoomListHeaderView /> compose menu should not display the compose menu
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
@@ -145,11 +145,11 @@ exports[`<RoomListHeaderView /> space menu should display the space menu 1`] = `
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"
@@ -232,11 +232,11 @@ exports[`<RoomListHeaderView /> space menu should not display the space menu 1`]
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="title"

View File

@@ -5,17 +5,17 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
<section
class="mx_Flex mx_RoomListPanel"
data-testid="room-list-panel"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<header
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="Home"
@@ -24,6 +24,65 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
</h1>
</div>
</header>
<ul
aria-label="Room list filters"
class="mx_Flex mx_RoomListPrimaryFilters"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Unread
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Favourites
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
People
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Rooms
</button>
</li>
</ul>
<div
class="mx_RoomList"
data-testid="room-list"
@@ -64,12 +123,12 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
<section
class="mx_Flex mx_RoomListPanel"
data-testid="room-list-panel"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_RoomListSearch_search _has-icon_vczzf_57"
@@ -92,7 +151,7 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
</svg>
<span
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
Search
<kbd>
@@ -126,11 +185,11 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
aria-label="Room options"
class="mx_Flex mx_RoomListHeaderView"
data-testid="room-list-header"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<div
class="mx_Flex mx_RoomListHeaderView_title"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<h1
title="Home"
@@ -174,6 +233,65 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
</div>
</button>
</header>
<ul
aria-label="Room list filters"
class="mx_Flex mx_RoomListPrimaryFilters"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Unread
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Favourites
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
People
</button>
</li>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Rooms
</button>
</li>
</ul>
<div
class="mx_RoomList"
data-testid="room-list"

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomListPrimaryFilters /> should render primary filters 1`] = `
<DocumentFragment>
<ul
aria-label="Room list filters"
class="mx_Flex mx_RoomListPrimaryFilters"
role="listbox"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: wrap;"
>
<li
aria-selected="false"
role="option"
>
<button
aria-selected="false"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
People
</button>
</li>
<li
aria-selected="true"
role="option"
>
<button
aria-selected="true"
class="_chat-filter_5qdp0_8"
role="button"
tabindex="0"
>
Rooms
</button>
</li>
</ul>
</DocumentFragment>
`;

View File

@@ -5,7 +5,7 @@ exports[`<RoomListSearch /> should display search and explore buttons 1`] = `
<div
class="mx_Flex mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_RoomListSearch_search _has-icon_vczzf_57"
@@ -28,7 +28,7 @@ exports[`<RoomListSearch /> should display search and explore buttons 1`] = `
</svg>
<span
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
Search
<kbd>
@@ -66,7 +66,7 @@ exports[`<RoomListSearch /> should display the dial button when the PTSN protoco
<div
class="mx_Flex mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_RoomListSearch_search _has-icon_vczzf_57"
@@ -89,7 +89,7 @@ exports[`<RoomListSearch /> should display the dial button when the PTSN protoco
</svg>
<span
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
Search
<kbd>
@@ -148,7 +148,7 @@ exports[`<RoomListSearch /> should hide the explore button when UIComponent.Expl
<div
class="mx_Flex mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_RoomListSearch_search _has-icon_vczzf_57"
@@ -171,7 +171,7 @@ exports[`<RoomListSearch /> should hide the explore button when UIComponent.Expl
</svg>
<span
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
Search
<kbd>
@@ -188,7 +188,7 @@ exports[`<RoomListSearch /> should hide the explore button when the active space
<div
class="mx_Flex mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 mx_RoomListSearch_search _has-icon_vczzf_57"
@@ -211,7 +211,7 @@ exports[`<RoomListSearch /> should hide the explore button when the active space
</svg>
<span
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
Search
<kbd>

View File

@@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@@ -215,7 +216,7 @@ describe("RoomTile", () => {
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");

View File

@@ -194,7 +194,7 @@ describe("<SendMessageComposer/>", () => {
"m.mentions": { user_ids: ["@bob:test"] },
});
// It also adds any other mentioned users, but removes yourself.
// It no longer adds any other mentioned users
replyToEvent = mkEvent({
type: "m.room.message",
user: "@bob:test",
@@ -205,7 +205,7 @@ describe("<SendMessageComposer/>", () => {
content = {};
attachMentions("@alice:test", content, model, replyToEvent);
expect(content).toEqual({
"m.mentions": { user_ids: ["@bob:test", "@charlie:test"] },
"m.mentions": { user_ids: ["@bob:test"] },
});
});

View File

@@ -50,11 +50,11 @@ exports[`<ThirdPartyMemberInfo /> should render invite 1`] = `
>
<div
class="mx_Flex mx_ThirdPartyMemberInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<section
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
@@ -124,11 +124,11 @@ exports[`<ThirdPartyMemberInfo /> should render invite when room in not availabl
>
<div
class="mx_Flex mx_ThirdPartyMemberInfo"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<section
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<span
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"

View File

@@ -232,7 +232,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
</div>
<div
class="mx_Flex mx_InvitedIconView"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0;"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<svg
fill="currentColor"

View File

@@ -84,7 +84,7 @@ exports[`<ResetIdentityPanel /> should display the 'forgot recovery key' variant
</div>
<div
class="mx_Flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<ul
class="_visual-list_15wzx_8"
@@ -266,7 +266,7 @@ exports[`<ResetIdentityPanel /> should reset the encryption when the continue bu
</div>
<div
class="mx_Flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<ul
class="_visual-list_15wzx_8"
@@ -451,7 +451,7 @@ exports[`<ResetIdentityPanel /> should reset the encryption when the continue bu
</div>
<div
class="mx_Flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<ul
class="_visual-list_15wzx_8"
@@ -555,7 +555,7 @@ exports[`<ResetIdentityPanel /> should reset the encryption when the continue bu
</button>
<div
class="mx_Flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<span
class="mx_ResetIdentityPanel_warning"

View File

@@ -255,7 +255,7 @@ exports[`<EncryptionUserSettingsTab /> should display the reset identity panel w
</div>
<div
class="mx_Flex mx_EncryptionCard_emphasisedContent"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x);"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: normal; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-3x); --mx-flex-wrap: nowrap;"
>
<ul
class="_visual-list_15wzx_8"

View File

@@ -34,7 +34,6 @@ import type { Mocked } from "jest-mock";
import type { ClientWidgetApi } from "matrix-widget-api";
import {
type JitsiCallMemberContent,
Layout,
Call,
CallEvent,
ConnectionState,
@@ -42,7 +41,6 @@ import {
ElementCall,
} from "../../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
@@ -52,18 +50,6 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics";
import { type SettingKey } from "../../../src/settings/Settings.tsx";
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [
{ deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} },
],
[MediaDeviceKindEnum.VideoInput]: [
{ deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} },
],
[MediaDeviceKindEnum.AudioOutput]: [],
});
jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName): any => enabledSettings.has(settingName) || undefined,
@@ -136,14 +122,7 @@ const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
};
const setUpWidget = (
call: Call,
): {
widget: Widget;
messaging: Mocked<ClientWidgetApi>;
audioMutedSpy: jest.SpyInstance<boolean, []>;
videoMutedSpy: jest.SpyInstance<boolean, []>;
} => {
const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked<ClientWidgetApi> } => {
call.widget.data = { ...call.widget, skipLobby: true };
const widget = new Widget(call.widget);
@@ -161,23 +140,45 @@ const setUpWidget = (
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
return { widget, messaging, audioMutedSpy, videoMutedSpy };
return { widget, messaging };
};
const cleanUpCallAndWidget = (
call: Call,
widget: Widget,
audioMutedSpy: jest.SpyInstance<boolean, []>,
videoMutedSpy: jest.SpyInstance<boolean, []>,
) => {
async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionConnect();
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
}
async function disconnect(call: Call, messaging: Mocked<ClientWidgetApi>): Promise<void> {
async function sessionDisconnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionDisconnect();
const promise = call.disconnect();
runTimers();
await promise;
}
const cleanUpCallAndWidget = (call: Call, widget: Widget) => {
call.destroy();
jest.clearAllMocks();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
};
describe("JitsiCall", () => {
@@ -221,8 +222,6 @@ describe("JitsiCall", () => {
let call: JitsiCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -233,7 +232,7 @@ describe("JitsiCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
@@ -251,102 +250,37 @@ describe("JitsiCall", () => {
});
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
it("connects muted", async () => {
it("connects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true);
videoMutedSpy.mockReturnValue(true);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
videoInput: null,
});
});
it("connects unmuted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(false);
videoMutedSpy.mockReturnValue(false);
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "Headphones",
videoInput: "Built-in webcam",
});
});
it("waits for messaging when connecting", async () => {
it("waits for messaging when starting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("doesn't stop messaging when connecting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
jest.useFakeTimers();
const oldSendMock = messaging.transport.send;
mocked(messaging.transport).send.mockImplementation(async (action: string): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
await new Promise((resolve) => setTimeout(resolve, 100));
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
}
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(1000);
}
async function runStopMessaging() {
await new Promise((resolve) => setTimeout(resolve, 1000));
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
}
runStopMessaging();
runTimers();
let connectError;
try {
await connect;
} catch (e) {
console.log(e);
connectError = e;
}
expect(connectError).toBeDefined();
// const connect2 = await connect;
// expect(connect2).toThrow();
messaging.transport.send = oldSendMock;
jest.useRealTimers();
});
it("fails to connect if the widget returns an error", async () => {
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.start()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await call.start();
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!"));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
const callback = jest.fn();
@@ -354,7 +288,6 @@ describe("JitsiCall", () => {
call.on(CallEvent.ConnectionState, callback);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => {
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected);
});
@@ -364,14 +297,14 @@ describe("JitsiCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
@@ -379,14 +312,14 @@ describe("JitsiCall", () => {
it("reconnects after disconnect in video rooms", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
@@ -412,7 +345,7 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.start();
await connect(call, messaging);
expect(call.participants).toEqual(
new Map([
[alice, new Set(["alices_device"])],
@@ -425,8 +358,8 @@ describe("JitsiCall", () => {
});
it("updates room state when connecting and disconnecting", async () => {
await connect(call, messaging);
const now1 = Date.now();
await call.start();
await waitFor(
() =>
expect(
@@ -453,7 +386,7 @@ describe("JitsiCall", () => {
});
it("repeatedly updates room state while connected", async () => {
await call.start();
await connect(call, messaging);
await waitFor(
() =>
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
@@ -483,7 +416,7 @@ describe("JitsiCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await call.start();
await connect(call, messaging);
await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
@@ -498,7 +431,7 @@ describe("JitsiCall", () => {
const onParticipants = jest.fn();
call.on(CallEvent.Participants, onParticipants);
await call.start();
await connect(call, messaging);
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
@@ -511,7 +444,7 @@ describe("JitsiCall", () => {
});
it("switches to spotlight layout when the widget becomes a PiP", async () => {
await call.start();
await connect(call, messaging);
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
@@ -555,7 +488,7 @@ describe("JitsiCall", () => {
});
it("doesn't clean up valid devices", async () => {
await call.start();
await connect(call, messaging);
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
@@ -620,47 +553,6 @@ describe("ElementCall", () => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
}
const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise<void> => {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
sessionId: undefined,
} as unknown as MatrixRTCSession);
call.session?.emit(
MatrixRTCSessionEvent.MembershipsChanged,
[],
[{ sender: client.getUserId() } as CallMembership],
);
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionConnect();
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
};
const callDisconnectionProcedure: (call: ElementCall) => Promise<void> = async (call) => {
async function sessionDisconnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
sessionId: undefined,
} as unknown as MatrixRTCSession);
call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionDisconnect();
const promise = call.disconnect();
runTimers();
await promise;
};
beforeEach(() => {
jest.useFakeTimers();
({ client, room, alice } = setUpClientRoomAndStores());
@@ -835,8 +727,6 @@ describe("ElementCall", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -847,34 +737,28 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
// TODO refactor initial device configuration to use the EW settings.
// Add tests for passing EW device configuration to the widget.
it("waits for messaging when connecting", async () => {
it("waits for messaging when starting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = callConnectProcedure(call);
const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("fails to connect if the widget returns an error", async () => {
// we only send a JoinCall action if the widget is preloading
call.widget.data = { ...call.widget, preload: true };
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.start()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
@@ -882,7 +766,7 @@ describe("ElementCall", () => {
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
@@ -892,62 +776,35 @@ describe("ElementCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("disconnects if the widget dies", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("tracks layout", async () => {
await callConnectProcedure(call);
expect(call.layout).toBe(Layout.Tile);
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(call.layout).toBe(Layout.Spotlight);
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(call.layout).toBe(Layout.Tile);
});
it("sets layout", async () => {
await callConnectProcedure(call);
await call.setLayout(Layout.Spotlight);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
await call.setLayout(Layout.Tile);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
});
it("acknowledges mute_device widget action", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const preventDefault = jest.fn();
const mockEv = {
preventDefault,
@@ -963,8 +820,8 @@ describe("ElementCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await callConnectProcedure(call);
await callDisconnectionProcedure(call);
await connect(call, messaging);
await disconnect(call, messaging);
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected],
@@ -985,29 +842,11 @@ describe("ElementCall", () => {
call.off(CallEvent.Participants, onParticipants);
});
it("emits events when layout changes", async () => {
await callConnectProcedure(call);
const onLayout = jest.fn();
call.on(CallEvent.Layout, onLayout);
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(onLayout.mock.calls).toEqual([[Layout.Spotlight], [Layout.Tile]]);
call.off(CallEvent.Layout, onLayout);
});
it("ends the call immediately if the session ended", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
// this will be called automatically
// disconnect -> widget sends state event -> session manager notices no-one left
client.matrixRTC.emit(
@@ -1043,39 +882,12 @@ describe("ElementCall", () => {
roomSpy.mockRestore();
addWidgetSpy.mockRestore();
});
it("sends notify event on connect in a room with more than two members", async () => {
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
"call_id": "",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "notify",
});
});
it("sends ring on create in a DM (two participants) room", async () => {
setRoomMembers(["@user:example.com", "@user2:example.com"]);
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
"call_id": "",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
});
});
});
describe("instance in a video room", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -1088,64 +900,29 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
it("doesn't end the call when the last participant leaves", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
expect(onDestroy).not.toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
it("connect to call with ongoing session", async () => {
// Mock membership getter used by `roomSessionForRoom`.
// This makes sure the roomSession will not be empty.
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [
{ fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership,
]);
// Create ongoing session
const roomSession = MatrixRTCSession.roomSessionForRoom(client, room);
const roomSessionEmitSpy = jest.spyOn(roomSession, "emit");
// Make sure the created session ends up in the call.
// `getActiveRoomSession` will be used during `call.connect`
// `getRoomSession` will be used during `Call.get`
client.matrixRTC.getActiveRoomSession.mockImplementation(() => {
return roomSession;
});
client.matrixRTC.getRoomSession.mockImplementation(() => {
return roomSession;
});
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
expect(call.session).toBe(roomSession);
await callConnectProcedure(call);
expect(roomSessionEmitSpy).toHaveBeenCalledWith(
"memberships_changed",
[],
[{ sender: "@alice:example.org" }],
);
expect(call.connectionState).toBe(ConnectionState.Connected);
call.destroy();
});
it("handles remote disconnection and reconnect right after", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
// We should now be able to reconnect without manually starting the widget
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call, false);
await connect(call, messaging, false);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
});
});

View File

@@ -28,6 +28,7 @@ import "../../../../../src/stores/room-list/RoomListStore"; // must be imported
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
import { CallStore } from "../../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../../src/models/Call";
describe("Algorithm", () => {
useMockedCalls();
@@ -83,7 +84,7 @@ describe("Algorithm", () => {
MockedCall.create(roomWithCall, "1");
const call = CallStore.instance.getCall(roomWithCall.roomId);
if (call === null) throw new Error("Failed to create call");
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
@@ -93,7 +94,7 @@ describe("Algorithm", () => {
// End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
await call.start();
call.setConnectionState(ConnectionState.Connected);
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);

View File

@@ -103,7 +103,7 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = `
</p>
<div
class="mx_Flex mx_ErrorView_buttons"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"
@@ -153,7 +153,7 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = `
</h2>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
>
<a
class="_button_vczzf_8 _has-icon_vczzf_57"
@@ -223,7 +223,7 @@ exports[`showIncompatibleBrowser should match snapshot 1`] = `
</h2>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x);"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-6x); --mx-flex-wrap: nowrap;"
>
<a
href="https://apps.apple.com/app/vector/id1083446067"

View File

@@ -2744,11 +2744,11 @@
"@svgr/plugin-svgo" "8.1.0"
"@testcontainers/postgresql@^10.16.0":
version "10.18.0"
resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-10.18.0.tgz#87d3acb3a4bc2196bd8e9b7496fe6e3146b59f2b"
integrity sha512-WxkE/tBlBpoKvqDEqL3i/mL6BOBWnXb8FXKtLhEeZ3lSt0zlldkTozMmewNsKJtFTBZdv7uFwMzWyXP12t0sxQ==
version "10.19.0"
resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-10.19.0.tgz#e1ff9fbfee76c23bc899865524ee8e2ee297bdf2"
integrity sha512-3+yQJHCWEtp4hylfZgRxCWN1P6dGqKhFM7Bypg22NpJqq1x/dcmamVCvD+4eTdm1uHV1Ta0BkHRWejxGOyTnrw==
dependencies:
testcontainers "^10.18.0"
testcontainers "^10.19.0"
"@testing-library/dom@^10.4.0":
version "10.4.0"
@@ -2916,9 +2916,9 @@
"@types/ssh2" "*"
"@types/dockerode@^3.3.29":
version "3.3.34"
resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.34.tgz#1cef62f1b98f80bd4460961dd8aac99b95a0fb6e"
integrity sha512-mH9SuIb8NuTDsMus5epcbTzSbEo52fKLBMo0zapzYIAIyfDqoIFn7L3trekHLKC8qmxGV++pPUP4YqQ9n5v2Zg==
version "3.3.35"
resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-3.3.35.tgz#d78c844a246f8717e3bcf2cc134a976bfa630b10"
integrity sha512-P+DCMASlsH+QaKkDpekKrP5pLls767PPs+/LrlVbKnEnY5tMpEUa2C6U4gRsdFZengOqxdCIqy16R22Q3pLB6Q==
dependencies:
"@types/docker-modem" "*"
"@types/node" "*"
@@ -3160,19 +3160,26 @@
"@types/node" "*"
"@types/node@*":
version "22.13.9"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.9.tgz#5d9a8f7a975a5bd3ef267352deb96fb13ec02eca"
integrity sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==
version "22.13.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.10.tgz#df9ea358c5ed991266becc3109dc2dc9125d77e4"
integrity sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==
dependencies:
undici-types "~6.20.0"
"@types/node@18", "@types/node@^18.11.18":
"@types/node@18":
version "18.19.79"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.79.tgz#82fde7ac17809f4738a494b22273f0f7e6754f6e"
integrity sha512-90K8Oayimbctc5zTPHPfZloc/lGVs7f3phUAAMcTgEPtg8kKquGZDERC8K4vkBYkQQh48msiYUslYtxTWvqcAg==
dependencies:
undici-types "~5.26.4"
"@types/node@^18.11.18":
version "18.19.80"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.80.tgz#6d6008e8920dddcd23f9dd33da24684ef57d487c"
integrity sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==
dependencies:
undici-types "~5.26.4"
"@types/normalize-package-data@^2.4.0":
version "2.4.4"
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
@@ -4334,9 +4341,9 @@ bare-fs@^4.0.1:
bare-stream "^2.0.0"
bare-os@^3.0.1:
version "3.4.0"
resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.4.0.tgz#97be31503f3095beb232a6871f0118859832eb0c"
integrity sha512-9Ous7UlnKbe3fMi7Y+qh0DwAup6A1JkYgPnjvMDNOlmnxNRQvQ/7Nst+OnUQKzk0iAT0m9BisbDVp9gCv8+ETA==
version "3.6.0"
resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.6.0.tgz#1465dd7e1bebe0dec230097a23ad00f7db51f957"
integrity sha512-BUrFS5TqSBdA0LwHop4OjPJwisqxGy6JsWVqV6qaFoe965qqtaKfDzHY5T2YA1gUL0ZeeQeA+4BBc1FJTcHiPw==
bare-path@^3.0.0:
version "3.0.0"
@@ -5071,19 +5078,19 @@ cronstrue@^2.41.0:
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.56.0.tgz#70b406106e6059bf9c494c788255d671d8ecc361"
integrity sha512-/YC3b4D/E/S8ToQ7f676A2fqoC3vVpXKjJ4SMsP0jYsvRYJdZ6h9+Fq/Y7FoFDEUFCqLTca+G2qTV227lyyFZg==
cross-spawn@^7.0.0, cross-spawn@^7.0.3:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
cross-spawn@^7.0.2:
version "7.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82"
integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
cross-spawn@^7.0.2:
version "7.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82"
integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
@@ -6640,11 +6647,11 @@ foreachasync@^3.0.0:
integrity sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==
foreground-child@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77"
integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
dependencies:
cross-spawn "^7.0.0"
cross-spawn "^7.0.6"
signal-exit "^4.0.1"
form-data@^4.0.0:
@@ -9052,9 +9059,9 @@ murmurhash-js@^1.0.0:
integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==
nan@^2.19.0, nan@^2.20.0:
version "2.22.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3"
integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==
version "2.22.2"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.2.tgz#6b504fd029fb8f38c0990e52ad5c26772fdacfbb"
integrity sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==
nanoid@^3.3.7, nanoid@^3.3.8:
version "3.3.8"
@@ -11944,10 +11951,10 @@ test-exclude@^6.0.0:
glob "^7.1.4"
minimatch "^3.0.4"
testcontainers@^10.16.0, testcontainers@^10.18.0:
version "10.18.0"
resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.18.0.tgz#dbd74e1d6e5de431414a06fb2c2ed9d82971739e"
integrity sha512-MnwWsPjsN5QVe+lSU1LwLZVOyjgwSwv1INzkw8FekdwgvOtvJ7FThQEkbmzRcguQootgwmA9FG54NoTChZDRvA==
testcontainers@^10.16.0, testcontainers@^10.19.0:
version "10.19.0"
resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-10.19.0.tgz#007138559c6de68c80334232a259f4e94fa19955"
integrity sha512-/mbcCOaj6jj2IPMMmt+YrBi71MZ4BqEzqicjAInsfEox4pVVMnYIW4CkWOdCLiuZ9nVUkoBtxFSJDTqggJNB5A==
dependencies:
"@balena/dockerignore" "^1.0.2"
"@types/dockerode" "^3.3.29"