Compare commits

...

35 Commits

Author SHA1 Message Date
David Baker
76abd064db Use getJoinedMembers
and add doc
2025-08-04 16:12:09 +01:00
David Baker
bc2cb7ac4c Remove client arg 2025-08-04 16:06:32 +01:00
David Baker
f646164540 Use getSafeUserId 2025-08-04 16:04:37 +01:00
David Baker
c63e6193a6 Use room.client 2025-08-04 16:04:12 +01:00
David Baker
56b34de568 Don't compute stuff if we don't need it 2025-08-04 16:03:50 +01:00
David Baker
dd0b7a9fff Add test for leave space dialog 2025-08-04 14:32:22 +01:00
David Baker
c42ba2a8c2 Merge branch 'develop' into dbkr/hydra 2025-08-04 13:36:25 +01:00
Florian Duros
7faee3d1b7 New room list: add tooltip for presence and room status (#30472)
* feat: add tooltip to room avatar

* test: update snapshots
2025-08-04 11:32:32 +00:00
renovate[bot]
30e7567064 Update dependency linkifyjs to v4.3.2 [SECURITY] (#30430)
* Update dependency linkifyjs to v4.3.2 [SECURITY]

* Bump the other linkify deps

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-08-03 21:20:35 +00:00
David Langley
2250f5e6a2 Fix: Clicking on an item in the member list causes it to scroll to the top rather than show the profile view (#30455)
* Fix issue and add test

* Fix MemberTileView

* Add e2e test and comment
2025-08-01 13:16:13 +00:00
Will Hunt
e43b696461 Kickoff an Element Web Pro build when a new Docker image is pushed (#30451)
* Kickoff an Element Web Pro build on successful docker push

* v3
2025-08-01 12:01:10 +00:00
ElementRobot
bf98ede4fa [create-pull-request] automated change (#30456)
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
2025-08-01 06:22:21 +00:00
David Baker
ddbf88a7b0 Merge remote-tracking branch 'origin/develop' into dbkr/hydra 2025-07-31 18:32:48 +01:00
David Baker
3eca1de385 Update for compatibility with v12 rooms
Stop using powerLevelNorm and reading PL events manually.

To support https://github.com/matrix-org/matrix-js-sdk/pull/4937
2025-07-31 18:18:02 +01:00
David Langley
cc0ece9837 Implement the member list with virtuoso (#29869)
* implement basic scrolling and keyboard navigation

* Update focus style and improve keyboard navigation

* lint

* Use avatar tootltip for the title rather than the whole button

It's more performant and feels less glitchy than the button tooltip moving around when you scroll.

* lint

* Add tooltip for invite buttons active state

As we have for other icon based buttons in the right panel/app

* Fix location of scrollToIndex and add useCallback

* Improve voiceover experience

- As well as stylng cells, set the tabIndex(roving)
- Natively focus the div with .focus() so screen reader actually moves over the cells
- improve labels and roles

* Fix jest tests

* Add aria index/counts and remove repeating "Open" string in label

* update snapshot

* Add the rest of the keyboard navigation and handle the case when the list looses focus.

* lint and update snapshot

* lint

* Only focus first/lastFocsed cell if focus.currentTarget is the overall list.

So it isn't erroneously called during onClick of an item.

* Put back overscan and fix formatting

* Extract ListView out of MemberList

* lint and fix e2e test

* Update screenshot

It looks like it is slightly better center aligned in the new list, as if maybe it was 1 px to high with the old one.

* Fix default overscan value and add ListView tests

* Just leave the avatar as it was

* We removed the tooltip that showed power level. Removing string.

* Use key rather than index to track focus.

* Remove overscan, fix typos, fix scrollToItem logic

* Use listbox role for member list and correct position/count values to account for the separator

* Fix inadvertant scrolling of the timeline when using pageUp/pageDown

* Always set the roving tab index regardless of whether we are actually focused.

Fixes the issue of not being able to shift+t

* Add aria-hidden to items within the option to avoid the SR calling it a group.

Also

* Make sure there is a roving tab set if the last one has been removed from the list.

* Update snapshot
2025-07-31 15:49:53 +00:00
Richard van der Hoff
ab6ef2fa85 Add labs option for history sharing on invite (#30313)
* Add labs option for "share history on invite"

* Set `acceptSharedHistory` when joining a room

* set `shareEncryptedHistory` when sending an invite

* Update src/i18n/strings/en_EN.json

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-31 14:20:37 +00:00
David Baker
c79c8c836b Put the 'decrypting' tooltip back (#30446)
...when downloading encrypted attachments (regressed by https://github.com/element-hq/element-web/pull/30330).

Also adds tests for the tooltips and fix the tests so they don't pollute
mocks / dialogs.
2025-07-31 14:20:33 +00:00
ElementRobot
3f0dcaa64c Playwright Docker image updates (#30406)
* [create-pull-request] automated change

* [create-pull-request] automated change

* Bump playwright-common

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: t3chguy <2403652+t3chguy@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-31 10:23:44 +00:00
Robin
652e891663 Stop using deprecated Element Call URL parameters (#30422)
These deprecated parameters will be removed very soon (planned for Element Call version 0.15.0) and we no longer have to care about backward compatibility with old versions of Element Call (due to the embedding/bundling work), so now is the right time to migrate.
2025-07-30 22:41:30 +00:00
Richard van der Hoff
7eb5a29cf0 Hacky fix to the MatrixChat flakiness (#30429)
Add a sleep to let these tests clean up.
2025-07-30 20:50:19 +00:00
RiotRobot
1b38624fd8 Merge branch 'master' into develop 2025-07-30 14:26:24 +00:00
RiotRobot
d98533025a v1.11.108 2025-07-30 14:22:51 +00:00
ElementRobot
c3e5367e45 Fix downloaded attachments not being decrypted (#30433) (#30434)
* Fix downloaded attachments not being decrypted

Fixes https://github.com/element-hq/element-web/issues/30339

* Import order

(cherry picked from commit 1e15a322a5)

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2025-07-30 13:57:09 +00:00
David Baker
1e15a322a5 Fix downloaded attachments not being decrypted (#30433)
* Fix downloaded attachments not being decrypted

Fixes https://github.com/element-hq/element-web/issues/30339

* Import order
2025-07-30 12:30:18 +00:00
Richard van der Hoff
452996eacf Playwright: clean up after verification test, to deflake later tests (#30425) 2025-07-30 12:11:14 +00:00
Will Hunt
ee120f2fa9 Use server name explicitly for via. (#30362)
* Use server name explicitly for via.

* lint
2025-07-29 17:40:56 +00:00
RiotRobot
94aa51dc57 Reset matrix-js-sdk back to develop branch 2025-07-29 13:08:36 +00:00
RiotRobot
e19d3dcd44 Merge branch 'master' into develop 2025-07-29 13:08:16 +00:00
RiotRobot
5a4b5418cc v1.11.107 2025-07-29 13:04:43 +00:00
RiotRobot
d1f62317ba Upgrade dependency to matrix-js-sdk@37.12.0 2025-07-29 13:01:13 +00:00
renovate[bot]
9232a220dc Update dependency filesize to v11 (#30380)
* Update dependency filesize to v11

* Update fileSize types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2025-07-29 10:15:11 +00:00
Richard van der Hoff
45a2fd9d63 Re-enable matrixchat test (#30410)
Now that we have better logging for our tests
(https://github.com/element-hq/element-web/pull/30405), I'd like to re-enable
this test so we can try and understsnd what makes it fail.
2025-07-28 21:07:20 +00:00
ElementRobot
db5c69e228 Fix e2e shield being invisible in white mode for encrypted room (#30408) (#30411)
Co-authored-by: Florian Duros <florianduros@element.io>
2025-07-28 09:58:20 +01:00
RiotRobot
acb3d31a07 v1.11.107-rc.0 2025-07-22 13:29:36 +00:00
RiotRobot
9136332f42 Upgrade dependency to matrix-js-sdk@37.12.0-rc.0 2025-07-22 13:25:38 +00:00
42 changed files with 1537 additions and 298 deletions

View File

@@ -139,3 +139,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: vectorim/element-web
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
if: github.event_name != 'pull_request'
with:
repository: element-hq/element-web-pro
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
event-type: image-built
# Stable way to determine the :version
client-payload: |-
{
"base-ref": "${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}"
}

View File

@@ -1,3 +1,29 @@
Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30)
====================================================================================================
## 🐛 Bug Fixes
* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot.
Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29)
====================================================================================================
## ✨ Features
* Message preview should show tooltip with the full message on hover ([#30265](https://github.com/element-hq/element-web/pull/30265)). Contributed by @MidhunSureshR.
* Support rendering notification badges on platforms that do their own icon overlays ([#30315](https://github.com/element-hq/element-web/pull/30315)). Contributed by @Half-Shot.
* Add SubscriptionViewModel base class ([#30297](https://github.com/element-hq/element-web/pull/30297)). Contributed by @dbkr.
* Enhancement: Save image on CTRL+S ([#30330](https://github.com/element-hq/element-web/pull/30330)). Contributed by @ioalexander.
* Add quote functionality to MessageContextMenu (#29893) ([#30323](https://github.com/element-hq/element-web/pull/30323)). Contributed by @AlirezaMrtz.
* Initial structure for shared component views ([#30216](https://github.com/element-hq/element-web/pull/30216)). Contributed by @dbkr.
## 🐛 Bug Fixes
* [Backport staging] Fix e2e shield being invisible in white mode for encrypted room ([#30411](https://github.com/element-hq/element-web/pull/30411)). Contributed by @RiotRobot.
* Force ED titlebar color for new room list ([#30332](https://github.com/element-hq/element-web/pull/30332)). Contributed by @florianduros.
* Add a background color to left panel for macos titlebar in element desktop ([#30328](https://github.com/element-hq/element-web/pull/30328)). Contributed by @florianduros.
* Fix: Prevent page refresh on Enter key in right panel member search ([#30312](https://github.com/element-hq/element-web/pull/30312)). Contributed by @AlirezaMrtz.
Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
====================================================================================================
## ✨ Features

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.106",
"version": "1.11.108",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -117,7 +117,7 @@
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.6",
"filesize": "11.0.2",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
@@ -128,9 +128,9 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-react": "4.3.1",
"linkify-string": "4.3.1",
"linkifyjs": "4.3.1",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
"lodash": "^4.17.21",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
@@ -154,6 +154,7 @@
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5",
"react-virtuoso": "^4.12.6",
"rfc4648": "^1.4.0",
"sanitize-filename": "^1.6.3",
"sanitize-html": "2.17.0",
@@ -186,7 +187,7 @@
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.13.1",
"@element-hq/element-web-playwright-common": "^1.4.3",
"@element-hq/element-web-playwright-common": "^1.4.4",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",

View File

@@ -124,6 +124,10 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
const toasts = new Toasts(page);
await toasts.rejectToast("Notifications");
await toasts.assertNoToasts();
// There may still be a `/sendToDevice/m.secret.request` in flight, which will later throw an error and cause
// a *subsequent* test to fail. Tell playwright to ignore any errors resulting from in-flight routes.
await page.unrouteAll({ behavior: "ignoreErrors" });
});
test("Verify device with QR code during login", async ({ page, app, credentials, homeserver }) => {

View File

@@ -30,6 +30,10 @@ test.describe("Lazy Loading", () => {
});
test.beforeEach(async ({ page, homeserver, user, bot, app }) => {
// The charlies were running off the bottom of the screen.
// We no longer overscan the member list so the result is they are not in the dom.
// Increase the viewport size to ensure they are.
await page.setViewportSize({ width: 1000, height: 1000 });
for (let i = 1; i <= 10; i++) {
const displayName = `Charly #${i}`;
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });

View File

@@ -11,6 +11,32 @@ import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room";
const NAME = "Alice";
async function setupRoomWithMembers(
app: any,
page: any,
homeserver: any,
roomName: string,
memberNames: string[],
): Promise<string> {
const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public);
const id = await app.client.createRoom({ name: roomName, visibility });
const bots: Bot[] = [];
for (let i = 0; i < memberNames.length; i++) {
const displayName = memberNames[i];
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
if (displayName === "Susan") {
await bot.prepareClient();
await app.client.inviteUser(id, bot.credentials?.userId);
} else {
await bot.joinRoom(id);
}
bots.push(bot);
}
return id;
}
test.use({
synapseConfig: {
presence: {
@@ -25,17 +51,8 @@ test.use({
test.describe("Memberlist", () => {
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
const id = await app.client.createRoom({ name: ROOM_NAME });
const newBots: Bot[] = [];
const names = ["Bob", "Bob", "Susan"];
for (let i = 0; i < 3; i++) {
const displayName = names[i];
const autoAcceptInvites = displayName !== "Susan";
const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites });
await bot.prepareClient();
await app.client.inviteUser(id, bot.credentials?.userId);
newBots.push(bot);
}
await setupRoomWithMembers(app, page, homeserver, ROOM_NAME, names);
});
test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => {
@@ -45,4 +62,37 @@ test.describe("Memberlist", () => {
await expect(memberlist.getByText("Invited")).toHaveCount(1);
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
});
test("should handle scroll and click to view member profile", async ({ page, app, homeserver }) => {
// Create a room with many members to enable scrolling
const memberNames = Array.from({ length: 15 }, (_, i) => `Member${i.toString()}`);
await setupRoomWithMembers(app, page, homeserver, "Large Room", memberNames);
// Navigate to the room and open member list
await app.viewRoomByName("Large Room");
const memberlist = await app.toggleMemberlistPanel();
// Get the scrollable container
const memberListContainer = memberlist.locator(".mx_AutoHideScrollbar");
// Scroll down to the bottom of the member list
await app.scrollListToBottom(memberListContainer);
// Wait for the target member to be visible after scrolling
const targetName = "Member14";
const targetMember = memberlist.locator(".mx_MemberTileView_name").filter({ hasText: targetName });
await targetMember.waitFor({ state: "visible" });
// Verify Alice is not visible at this point
await expect(memberlist.locator(".mx_MemberTileView_name").filter({ hasText: "Alice" })).toHaveCount(0);
// Click on a member near the bottom of the list
await expect(targetMember).toBeVisible();
await targetMember.click();
// Verify that the user info screen is shown and hasn't scrolled back to top
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(targetName)).toBeVisible();
});
});

View File

@@ -35,7 +35,7 @@ test.describe("Share dialog", () => {
const rightPanel = await app.toggleRoomInfoPanel();
await rightPanel.getByRole("menuitem", { name: "People" }).click();
await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click();
await rightPanel.getByRole("option", { name: user.displayName }).click();
await rightPanel.getByRole("button", { name: "Share profile" }).click();
const dialog = page.getByRole("dialog", { name: "Share User" });

View File

@@ -23,7 +23,7 @@ async function openSpaceContextMenu(page: Page, app: ElementAppPage, spaceName:
return page.locator(".mx_SpacePanel_contextMenu");
}
function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts {
function spaceCreateOptions(serverName: string, spaceName: string, roomIds: string[] = []): ICreateRoomOpts {
return {
creation_content: {
type: "m.space",
@@ -35,17 +35,21 @@ function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateR
name: spaceName,
},
},
...roomIds.map((r) => spaceChildInitialState(r)),
...roomIds.map((r) => spaceChildInitialState(serverName, r)),
],
};
}
function spaceChildInitialState(roomId: string, order?: string): ICreateRoomOpts["initial_state"]["0"] {
function spaceChildInitialState(
serverName: string,
roomId: string,
order?: string,
): ICreateRoomOpts["initial_state"]["0"] {
return {
type: "m.space.child",
state_key: roomId,
content: {
via: [roomId.split(":")[1]],
via: [serverName],
order,
},
};
@@ -240,7 +244,7 @@ test.describe("Spaces", () => {
});
await expect(await app.getSpacePanelButton("My Space")).toBeVisible();
const roomId = await bot.createRoom(spaceCreateOptions("Space Space"));
const roomId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Space Space"));
await bot.inviteUser(roomId, user.userId);
// Assert that `Space Space` is above `My Space` due to it being an invite
@@ -260,7 +264,10 @@ test.describe("Spaces", () => {
const spaceName = "Spacey Mc. Space Space";
await app.client.createSpace({
name: spaceName,
initial_state: [spaceChildInitialState(roomId1), spaceChildInitialState(roomId2)],
initial_state: [
spaceChildInitialState(user.homeServer, roomId1),
spaceChildInitialState(user.homeServer, roomId2),
],
});
await app.viewSpaceHomeByName(spaceName);
@@ -287,7 +294,7 @@ test.describe("Spaces", () => {
});
await app.client.createSpace({
name: "Root Space",
initial_state: [spaceChildInitialState(childSpaceId)],
initial_state: [spaceChildInitialState(user.homeServer, childSpaceId)],
});
// Find collapsed Space panel
@@ -323,7 +330,7 @@ test.describe("Spaces", () => {
name: "Test Room",
topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link",
});
const spaceId = await bot.createRoom(spaceCreateOptions("Test Space", [roomId]));
const spaceId = await bot.createRoom(spaceCreateOptions(user.homeServer, "Test Space", [roomId]));
await bot.inviteUser(spaceId, user.userId);
await expect(await app.getSpacePanelButton("Test Space")).toBeVisible();
@@ -361,9 +368,9 @@ test.describe("Spaces", () => {
await app.client.createSpace({
name: "Root Space",
initial_state: [
spaceChildInitialState(childSpaceId1, "a"),
spaceChildInitialState(childSpaceId2, "b"),
spaceChildInitialState(childSpaceId3, "c"),
spaceChildInitialState(user.homeServer, childSpaceId1, "a"),
spaceChildInitialState(user.homeServer, childSpaceId2, "b"),
spaceChildInitialState(user.homeServer, childSpaceId3, "c"),
],
});
await app.viewSpaceByName("Root Space");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:8c2d9a93dd209a79d3e5e50cd18addfe52d80bea0ffe48a5d3e15836032eeb9d";
const TAG = "develop@sha256:8e478cf4f135467287c17687e80fd859f70db23e1d6cd35a853369ff423c9773";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -141,6 +141,7 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
import Markdown from "../../Markdown";
import { sanitizeHtmlParams } from "../../Linkify";
import { isOnlyAdmin } from "../../utils/membership";
// legacy export
export { default as Views } from "../../Views";
@@ -1255,29 +1256,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const client = MatrixClientPeg.get();
if (client && roomToLeave) {
const plEvent = roomToLeave.currentState.getStateEvents(EventType.RoomPowerLevels, "");
const plContent = plEvent ? plEvent.getContent() : {};
const userLevels = plContent.users || {};
const currentUserLevel = userLevels[client.getUserId()!];
const userLevelValues = Object.values(userLevels);
if (userLevelValues.every((x) => typeof x === "number")) {
// If the user is the only user with highest power level
if (isOnlyAdmin(roomToLeave)) {
const userLevelValues = roomToLeave.getMembers().map((m) => m.powerLevel);
const maxUserLevel = Math.max(...(userLevelValues as number[]));
// If the user is the only user with highest power level
if (
maxUserLevel === currentUserLevel &&
userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel)
) {
const warning =
maxUserLevel >= 100
? _t("leave_room_dialog|room_leave_admin_warning")
: _t("leave_room_dialog|room_leave_mod_warning");
warnings.push(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}
const warning =
maxUserLevel >= 100
? _t("leave_room_dialog|room_leave_admin_warning")
: _t("leave_room_dialog|room_leave_mod_warning");
warnings.push(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}
}

View File

@@ -0,0 +1,291 @@
/*
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, { useRef, type JSX, useCallback, useEffect, useState } from "react";
import { type VirtuosoHandle, type ListRange, Virtuoso, type VirtuosoProps } from "react-virtuoso";
/**
* Context object passed to each list item containing the currently focused key
* and any additional context data from the parent component.
*/
export type ListContext<Context> = {
/** The key of item that should have tabIndex == 0 */
tabIndexKey?: string;
/** Whether an item in the list is currently focused */
focused: boolean;
/** Additional context data passed from the parent component */
context: Context;
};
export interface IListViewProps<Item, Context>
extends Omit<VirtuosoProps<Item, ListContext<Context>>, "data" | "itemContent" | "context"> {
/**
* The array of items to display in the virtualized list.
* Each item will be passed to getItemComponent for rendering.
*/
items: Item[];
/**
* Callback function called when an item is selected (via Enter/Space key).
* @param item - The selected item from the items array
*/
onSelectItem: (item: Item) => void;
/**
* Function that renders each list item as a JSX element.
* @param index - The index of the item in the list
* @param item - The data item to render
* @param context - The context object containing the focused key and any additional data
* @returns JSX element representing the rendered item
*/
getItemComponent: (
index: number,
item: Item,
context: ListContext<Context>,
onFocus: (e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Optional additional context data to pass to each rendered item.
* This will be available in the ListContext passed to getItemComponent.
*/
context?: Context;
/**
* Function to determine if an item can receive focus during keyboard navigation.
* @param item - The item to check for focusability
* @returns true if the item can be focused, false otherwise
*/
isItemFocusable: (item: Item) => boolean;
/**
* Function to get the key to use for focusing an item.
* @param item - The item to get the key for
* @return The key to use for focusing the item
*/
getItemKey: (item: Item) => string;
}
/**
* A generic virtualized list component built on top of react-virtuoso.
* Provides keyboard navigation and virtualized rendering for performance with large lists.
*
* @template Item - The type of data items in the list
* @template Context - The type of additional context data passed to items
*/
export function ListView<Item, Context = any>(props: IListViewProps<Item, Context>): React.ReactElement {
// Extract our custom props to avoid conflicts with Virtuoso props
const { items, onSelectItem, getItemComponent, isItemFocusable, getItemKey, context, ...virtuosoProps } = props;
/** Reference to the Virtuoso component for programmatic scrolling */
const virtuosoHandleRef = useRef<VirtuosoHandle>(null);
/** Reference to the DOM element containing the virtualized list */
const virtuosoDomRef = useRef<HTMLElement | Window>(null);
/** Key of the item that should have tabIndex == 0 */
const [tabIndexKey, setTabIndexKey] = useState<string | undefined>(
props.items[0] ? getItemKey(props.items[0]) : undefined,
);
/** Range of currently visible items in the viewport */
const [visibleRange, setVisibleRange] = useState<ListRange | undefined>(undefined);
/** Map from item keys to their indices in the items array */
const [keyToIndexMap, setKeyToIndexMap] = useState<Map<string, number>>(new Map());
/** Whether the list is currently scrolling to an item */
const isScrollingToItem = useRef<boolean>(false);
/** Whether the list is currently focused */
const [isFocused, setIsFocused] = useState<boolean>(false);
// Update the key-to-index mapping whenever items change
useEffect(() => {
const newKeyToIndexMap = new Map<string, number>();
items.forEach((item, index) => {
const key = getItemKey(item);
newKeyToIndexMap.set(key, index);
});
setKeyToIndexMap(newKeyToIndexMap);
}, [items, getItemKey]);
// Ensure the tabIndexKey is set if there is none already or if the existing key is no longer displayed
useEffect(() => {
if (items.length && (!tabIndexKey || keyToIndexMap.get(tabIndexKey) === undefined)) {
setTabIndexKey(getItemKey(items[0]));
}
}, [items, getItemKey, tabIndexKey, keyToIndexMap]);
/**
* Scrolls to a specific item index and sets it as focused.
* Uses Virtuoso's scrollIntoView method for smooth scrolling.
*/
const scrollToIndex = useCallback(
(index: number, align?: "center" | "end" | "start"): void => {
// Ensure index is within bounds
const clampedIndex = Math.max(0, Math.min(index, items.length - 1));
if (isScrollingToItem.current) {
// If already scrolling to an item drop this request. Adding further requests
// causes the event to bubble up and be handled by other components(unintentional timeline scrolling was observed).
return;
}
if (items[clampedIndex]) {
const key = getItemKey(items[clampedIndex]);
setTabIndexKey(key);
isScrollingToItem.current = true;
virtuosoHandleRef?.current?.scrollIntoView({
index: clampedIndex,
align: align,
behavior: "auto",
done: () => {
isScrollingToItem.current = false;
},
});
}
},
[items, getItemKey],
);
/**
* Scrolls to an item, skipping over non-focusable items if necessary.
* This is used for keyboard navigation to ensure focus lands on valid items.
*/
const scrollToItem = useCallback(
(index: number, isDirectionDown: boolean, align?: "center" | "end" | "start"): void => {
const totalRows = items.length;
let nextIndex: number | undefined;
for (let i = index; isDirectionDown ? i < totalRows : i >= 0; i = i + (isDirectionDown ? 1 : -1)) {
if (isItemFocusable(items[i])) {
nextIndex = i;
break;
}
}
if (nextIndex === undefined) {
return;
}
scrollToIndex(nextIndex, align);
},
[scrollToIndex, items, isItemFocusable],
);
/**
* Handles keyboard navigation for the list.
* Supports Arrow keys, Home, End, Page Up/Down, Enter, and Space.
*/
const keyDownCallback = useCallback(
(e: React.KeyboardEvent) => {
if (!e) return; // Guard against null/undefined events
const currentIndex = tabIndexKey ? keyToIndexMap.get(tabIndexKey) : undefined;
let handled = false;
if (e.code === "ArrowUp" && currentIndex !== undefined) {
scrollToItem(currentIndex - 1, false);
handled = true;
} else if (e.code === "ArrowDown" && currentIndex !== undefined) {
scrollToItem(currentIndex + 1, true);
handled = true;
} else if ((e.code === "Enter" || e.code === "Space") && currentIndex !== undefined) {
const item = items[currentIndex];
onSelectItem(item);
handled = true;
} else if (e.code === "Home") {
scrollToIndex(0);
handled = true;
} else if (e.code === "End") {
scrollToIndex(items.length - 1);
handled = true;
} else if (e.code === "PageDown" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.min(currentIndex + numberDisplayed, items.length - 1), true, `start`);
handled = true;
} else if (e.code === "PageUp" && visibleRange && currentIndex !== undefined) {
const numberDisplayed = visibleRange.endIndex - visibleRange.startIndex;
scrollToItem(Math.max(currentIndex - numberDisplayed, 0), false, `start`);
handled = true;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
},
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onSelectItem],
);
/**
* Callback ref for the Virtuoso scroller element.
* Stores the reference for use in focus management.
*/
const scrollerRef = useCallback((element: HTMLElement | Window | null) => {
virtuosoDomRef.current = element;
}, []);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: ListContext<Context>): JSX.Element => {
const onFocus = (e: React.FocusEvent): void => {
// If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the ListViews onFocus doesn't also handle it.
const key = getItemKey(item);
setIsFocused(true);
setTabIndexKey(key);
e.stopPropagation();
};
return getItemComponent(index, item, context, onFocus);
},
[getItemComponent, getItemKey],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
*/
const onFocus = useCallback(
(e?: React.FocusEvent): void => {
if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") {
return;
}
setIsFocused(true);
const index = keyToIndexMap.get(tabIndexKey);
if (
index !== undefined &&
visibleRange &&
(index < visibleRange.startIndex || index > visibleRange.endIndex)
) {
scrollToIndex(index);
}
e?.stopPropagation();
e?.preventDefault();
},
[keyToIndexMap, visibleRange, scrollToIndex, tabIndexKey],
);
const onBlur = useCallback((): void => {
setIsFocused(false);
}, []);
const listContext: ListContext<Context> = {
tabIndexKey: tabIndexKey,
focused: isFocused,
context: props.context || ({} as Context),
};
return (
<Virtuoso
tabIndex={props.tabIndex || undefined} // We don't need to focus the container, so leave it undefined by default
scrollerRef={scrollerRef}
ref={virtuosoHandleRef}
onKeyDown={keyDownCallback}
context={listContext}
rangeChanged={setVisibleRange}
// virtuoso errors internally if you pass undefined.
overscan={props.overscan || 0}
data={props.items}
onFocus={onFocus}
onBlur={onBlur}
itemContent={getItemComponentInternal}
{...virtuosoProps}
/>
);
}

View File

@@ -38,6 +38,8 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
import { type XOR } from "../../../@types/common";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { Action } from "../../../dispatcher/actions";
import dis from "../../../dispatcher/dispatcher";
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
@@ -111,6 +113,7 @@ export interface MemberListViewState {
shouldShowSearch: boolean;
isLoading: boolean;
canInvite: boolean;
onClickMember: (member: RoomMember | ThreePIDInvite) => void;
onInviteButtonClick: (ev: ButtonEvent) => void;
}
export function useMemberListViewModel(roomId: string): MemberListViewState {
@@ -133,6 +136,14 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
*/
const [memberCount, setMemberCount] = useState(0);
const onClickMember = (member: RoomMember | ThreePIDInvite): void => {
dis.dispatch({
action: Action.ViewUser,
member: member,
push: true,
});
};
const loadMembers = useMemo(
() =>
throttle(
@@ -267,6 +278,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState {
isPresenceEnabled,
isLoading,
onInviteButtonClick,
onClickMember,
shouldShowSearch: totalMemberCount >= 20,
canInvite,
};

View File

@@ -5,7 +5,7 @@ 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 { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
@@ -16,7 +16,6 @@ import { asyncSome } from "../../../../utils/arrays";
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
import { type RoomMember } from "../../../../models/rooms/RoomMember";
import { _t, _td, type TranslationKey } from "../../../../languageHandler";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
import { E2EStatus } from "../../../../utils/ShieldUtils";
interface MemberTileViewModelProps {
@@ -28,7 +27,6 @@ export interface MemberTileViewState extends MemberTileViewModelProps {
e2eStatus?: E2EStatus;
name: string;
onClick: () => void;
title?: string;
userLabel?: string;
}
@@ -130,15 +128,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
}
}
const title = useMemo(() => {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
roomId: member.roomId,
}),
powerLevelNumber: member.powerLevel,
}).trim();
}, [member.powerLevel, member.roomId, member.userId]);
let userLabel;
const powerStatus = powerStatusMap.get(powerLevel);
if (powerStatus) {
@@ -149,7 +138,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT
}
return {
title,
member,
name,
onClick,

View File

@@ -14,6 +14,7 @@ import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/we
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import RoomAvatar from "./RoomAvatar";
import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
@@ -37,6 +38,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence);
const label = getDecorationLabel(vm.badgeDecoration, vm.presence);
// Presence indicator and video/public icons don't have the same size
// We use different masks
@@ -48,7 +50,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
return (
<div className="mx_RoomAvatarView">
<RoomAvatar className={classNames("mx_RoomAvatarView_RoomAvatar", maskClass)} size="32px" room={room} />
{icon}
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
</div>
);
}
@@ -72,7 +74,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-accent-primary)"
aria-label={_t("presence|online")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Away:
@@ -82,7 +84,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-quaternary)"
aria-label={_t("presence|away")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Offline:
@@ -92,7 +94,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|offline")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Busy:
@@ -102,7 +104,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|busy")}
aria-label={getPresenceLabel(presence)}
/>
);
}
@@ -116,7 +118,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|room_is_low_priority")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.VideoRoom) {
@@ -126,7 +128,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|video_room")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.PublicRoom) {
@@ -136,10 +138,44 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-info-primary)"
aria-label={_t("room|header|room_is_public")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.Presence) {
return <PresenceDecoration presence={presence!} />;
}
}
/**
* Get the label for the avatar decoration.
* This is used for the tooltip and a11y label.
*/
function getDecorationLabel(decoration: AvatarBadgeDecoration, presence: Presence | null): string | undefined {
switch (decoration) {
case AvatarBadgeDecoration.LowPriority:
return _t("room|room_is_low_priority");
case AvatarBadgeDecoration.VideoRoom:
return _t("room|video_room");
case AvatarBadgeDecoration.PublicRoom:
return _t("room|header|room_is_public");
case AvatarBadgeDecoration.Presence:
return getPresenceLabel(presence!);
}
}
/**
* Get the label for the presence.
* This is used for the tooltip and a11y label.
*/
function getPresenceLabel(presence: Presence): string {
switch (presence) {
case Presence.Online:
return _t("presence|online");
case Presence.Away:
return _t("presence|away");
case Presence.Offline:
return _t("presence|offline");
case Presence.Busy:
return _t("presence|busy");
}
}

View File

@@ -15,23 +15,13 @@ import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { filterBoolean } from "../../../utils/arrays";
import { isOnlyAdmin } from "../../../utils/membership";
interface IProps {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
const userId = room.client.getSafeUserId();
if (room.getMember(userId)?.powerLevelNorm !== 100) {
return false; // user is not an admin
}
return room.getJoinedMembers().every((member) => {
// return true if every other member has a lower power level (we are highest)
return member.userId === userId || member.powerLevelNorm < 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => {
const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId));

View File

@@ -26,6 +26,12 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined;
}
function useButtonTitle(loading: boolean, isEncrypted: boolean): string {
if (!loading) return _t("action|download");
return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading");
}
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
@@ -33,6 +39,8 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false);
if (!canDownload) return null;
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
@@ -45,7 +53,7 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I
return (
<RovingAccessibleButton
className={classes}
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
title={buttonTitle}
onClick={download}
disabled={loading}
placement="left"

View File

@@ -19,10 +19,10 @@ interface TooltipProps {
children: React.ReactNode;
}
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
if (canInvite) return children;
const InviteTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
const description: string = canInvite ? _t("action|invite") : _t("member_list|invite_button_no_perms_tooltip");
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
return <Tooltip description={description}>{children}</Tooltip>;
};
interface Props {
@@ -42,7 +42,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
if (shouldShowSearch) {
/// When rendered alongside a search box, the invite button is just an icon.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<InviteTooltip canInvite={vm.canInvite}>
<Button
className="mx_MemberListHeaderView_invite_small"
kind="secondary"
@@ -54,13 +54,13 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
aria-label={_t("action|invite")}
type="button"
/>
</OptionalTooltip>
</InviteTooltip>
);
}
// Without a search box, invite button is a full size button.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<InviteTooltip canInvite={vm.canInvite}>
<Button
kind="secondary"
size="sm"
@@ -72,7 +72,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
>
{_t("action|invite")}
</Button>
</OptionalTooltip>
</InviteTooltip>
);
};

View File

@@ -6,9 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { Form } from "@vector-im/compound-web";
import React, { type JSX } from "react";
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
import { AutoSizer } from "react-virtualized";
import React, { type JSX, useCallback } from "react";
import { Flex } from "../../../../shared-components/utils/Flex";
import {
@@ -21,7 +19,7 @@ import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler";
import { RovingTabIndexProvider } from "../../../../accessibility/RovingTabIndex";
import { type ListContext, ListView } from "../../../utils/ListView";
interface IProps {
roomId: string;
@@ -30,53 +28,74 @@ interface IProps {
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const { isPresenceEnabled, onClickMember, memberCount } = vm;
const totalRows = vm.members.length;
const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
const getItemKey = useCallback((item: MemberWithSeparator): string => {
if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />;
return "separator";
} else if (item.member) {
return <RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />;
return `member-${item.member.userId}`;
} else {
return <ThreePidInviteTileView threePidInvite={item.threePidInvite} />;
return `threePidInvite-${item.threePidInvite.event.getContent().public_key}`;
}
};
}, []);
const getRowHeight = ({ index }: { index: number }): number => {
if (vm.members[index] === SEPARATOR) {
/**
* This is a separator of 2px height rendered between
* joined and invited members.
*/
return 2;
} else if (totalRows && index === totalRows) {
/**
* The empty spacer div rendered at the bottom should
* have a height of 32px.
*/
return 32;
} else {
/**
* The actual member tiles have a height of 56px.
*/
return 56;
}
};
const getItemComponent = useCallback(
(
index: number,
item: MemberWithSeparator,
context: ListContext<any>,
onFocus: (e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = getItemKey(item);
const isRovingItem = itemKey === context.tabIndexKey;
const focused = isRovingItem && context.focused;
if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />;
} else if (item.member) {
return (
<RoomMemberTileView
member={item.member}
showPresence={isPresenceEnabled}
focused={focused}
tabIndex={isRovingItem ? 0 : -1}
index={index}
memberCount={memberCount}
onFocus={onFocus}
/>
);
} else {
return (
<ThreePidInviteTileView
threePidInvite={item.threePidInvite}
focused={focused}
tabIndex={isRovingItem ? 0 : -1}
memberIndex={index - 1} // Adjust as invites are below the separator
memberCount={memberCount}
onFocus={onFocus}
/>
);
}
},
[isPresenceEnabled, getItemKey, memberCount],
);
const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
if (index === totalRows) {
// We've rendered all the members,
// now we render an empty div to add some space to the end of the list.
return <div key={key} style={style} />;
}
const item = vm.members[index];
return (
<div key={key} style={style}>
{getRowComponent(item)}
</div>
);
};
const handleSelectItem = useCallback(
(item: MemberWithSeparator): void => {
if (item !== SEPARATOR) {
if (item.member) {
onClickMember(item.member);
} else {
onClickMember(item.threePidInvite);
}
}
},
[onClickMember],
);
const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => {
return item !== SEPARATOR;
}, []);
return (
<BaseCard
@@ -87,34 +106,20 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
header={_t("common|people")}
onClose={props.onClose}
>
<RovingTabIndexProvider handleUpDown scrollIntoView>
{({ onKeyDownHandler }) => (
<Flex
align="stretch"
direction="column"
className="mx_MemberListView_container"
onKeyDown={onKeyDownHandler}
>
<Form.Root onSubmit={(e) => e.preventDefault()}>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
rowHeight={getRowHeight}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={totalRows + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
overscanRowCount={15}
/>
)}
</AutoSizer>
</Flex>
)}
</RovingTabIndexProvider>
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
<Form.Root onSubmit={(e) => e.preventDefault()}>
<MemberListHeaderView vm={vm} />
</Form.Root>
<ListView
items={vm.members}
onSelectItem={handleSelectItem}
getItemComponent={getItemComponent}
getItemKey={getItemKey}
isItemFocusable={isItemFocusable}
role="listbox"
aria-label={_t("member_list|list_title")}
/>
</Flex>
</BaseCard>
);
};

View File

@@ -19,7 +19,12 @@ import { InvitedIconView } from "./common/InvitedIconView";
interface IProps {
member: RoomMember;
index: number;
memberCount: number;
showPresence?: boolean;
focused?: boolean;
tabIndex?: number;
onFocus: (e: React.FocusEvent) => void;
}
export function RoomMemberTileView(props: IProps): JSX.Element {
@@ -36,7 +41,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
/>
);
const name = vm.name;
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
const nameJSX = <DisambiguatedProfile withTooltip member={member} fallbackName={name || ""} />;
const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined;
@@ -54,13 +59,18 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
return (
<MemberTileView
title={vm.title}
onClick={vm.onClick}
onFocus={props.onFocus}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabel={vm.userLabel}
ariaLabel={name}
iconJsx={iconJsx}
focused={props.focused}
tabIndex={props.tabIndex}
memberIndex={props.index - (member.isInvite ? 1 : 0)} // Adjust as invites are below the seperator
memberCount={props.memberCount}
/>
);
}

View File

@@ -15,20 +15,32 @@ import { InvitedIconView } from "./common/InvitedIconView";
interface Props {
threePidInvite: ThreePIDInvite;
memberIndex: number;
memberCount: number;
focused?: boolean;
tabIndex?: number;
onFocus: (e: React.FocusEvent) => void;
}
export function ThreePidInviteTileView(props: Props): JSX.Element {
const vm = useThreePidTileViewModel(props);
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
const iconJsx = <InvitedIconView isThreePid={true} />;
const name = vm.name;
return (
<MemberTileView
nameJsx={vm.name}
nameJsx={name}
avatarJsx={av}
onClick={vm.onClick}
memberIndex={props.memberIndex}
memberCount={props.memberCount}
ariaLabel={name}
userLabel={vm.userLabel}
iconJsx={iconJsx}
focused={props.focused}
tabIndex={props.tabIndex}
onFocus={props.onFocus}
/>
);
}

View File

@@ -5,18 +5,23 @@ 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 JSX } from "react";
import React, { useEffect, useRef, type JSX } from "react";
import { RovingAccessibleButton } from "../../../../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../../../elements/AccessibleButton";
interface Props {
avatarJsx: JSX.Element;
nameJsx: JSX.Element | string;
onClick: () => void;
title?: string;
onFocus: (e: React.FocusEvent) => void;
memberIndex: number;
memberCount: number;
ariaLabel?: string;
presenceJsx?: JSX.Element;
userLabel?: React.ReactNode;
iconJsx?: JSX.Element;
tabIndex?: number;
focused?: boolean;
}
export function MemberTileView(props: Props): JSX.Element {
@@ -24,22 +29,37 @@ export function MemberTileView(props: Props): JSX.Element {
if (props.userLabel) {
userLabelJsx = <div className="mx_MemberTileView_userLabel">{props.userLabel}</div>;
}
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (props.focused) {
ref.current?.focus({ preventScroll: true, focusVisible: true });
}
}, [props.focused]);
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
<RovingAccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<div className="mx_MemberTileView_left">
<AccessibleButton
ref={ref}
className="mx_MemberTileView"
onClick={props.onClick}
onFocus={props.onFocus}
aria-label={props?.ariaLabel}
tabIndex={props.tabIndex}
role="option"
aria-posinset={props.memberIndex + 1}
aria-setsize={props.memberCount}
>
<div aria-hidden className="mx_MemberTileView_left">
<div className="mx_MemberTileView_avatar">
{props.avatarJsx} {props.presenceJsx}
</div>
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
<div aria-hidden className="mx_MemberTileView_right">
{userLabelJsx}
{props.iconJsx}
</div>
</RovingAccessibleButton>
</AccessibleButton>
</div>
);
}

View File

@@ -59,15 +59,19 @@ export function useDownloadMedia(url: string, fileName?: string, mxEvent?: Matri
return downloadBlob(blobRef.current);
}
const res = await fetch(url);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
// We must download via the mediaEventHelper if given as the file may need decryption.
if (mediaEventHelper) {
blobRef.current = await mediaEventHelper.sourceBlob.value;
} else {
const res = await fetch(url);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
blobRef.current = await res.blob();
}
const blob = await res.blob();
blobRef.current = blob;
await downloadBlob(blob);
await downloadBlob(blobRef.current);
} catch (e) {
showError(e);
}

View File

@@ -1534,6 +1534,9 @@
"render_reaction_images_description": "Sometimes referred to as \"custom emojis\".",
"report_to_moderators": "Report to moderators",
"report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.",
"share_history_on_invite": "Share encrypted history with new members",
"share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.",
"share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.",
"sliding_sync": "Sliding Sync mode",
"sliding_sync_description": "Under active development, cannot be disabled.",
"sliding_sync_disabled_notice": "Log out and back in to disable",
@@ -1655,8 +1658,8 @@
"filter_placeholder": "Search room members",
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
"invited_label": "Invited",
"no_matches": "No matches",
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
"list_title": "Member list",
"no_matches": "No matches"
},
"member_list_back_action_label": "Room members",
"message_edit_dialog_title": "Message edits",
@@ -3373,6 +3376,7 @@
"unable_to_decrypt": "Unable to decrypt message"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file",

View File

@@ -669,12 +669,12 @@ export class ElementCall extends Call {
// Splice together the Element Call URL for this call
const params = new URLSearchParams({
embed: "true", // We're embedding EC within another application
confineToRoom: "true", // Only show the call interface for the configured room
// Template variables are used, so that this can be configured using the widget data.
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",
hideHeader: "true", // Hide the header since our room header is enough
header: "none", // Hide the header since our room header is enough
userId: client.getUserId()!,
deviceId: client.getDeviceId()!,
roomId: roomId,

View File

@@ -205,6 +205,7 @@ export interface Settings {
"feature_mjolnir": IFeature;
"feature_custom_themes": IFeature;
"feature_exclude_insecure_devices": IFeature;
"feature_share_history_on_invite": IFeature;
"feature_html_topic": IFeature;
"feature_bridge_state": IFeature;
"feature_jump_to_date": IFeature;
@@ -503,6 +504,29 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true,
default: false,
},
"feature_share_history_on_invite": {
isFeature: true,
labsGroup: LabGroup.Encryption,
displayName: _td("labs|share_history_on_invite"),
description: () => (
<>
{_t("labs|share_history_on_invite_description")}
<div className="mx_SettingsFlag_microcopy">
{_t(
"settings|warning",
{},
{
w: (sub) => <span className="mx_SettingsTab_microcopy_warning">{sub}</span>,
description: _t("labs|share_history_on_invite_warning"),
},
)}
</div>
</>
),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
default: false,
},
"useOnlyCurrentProfiles": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|disable_historical_profile"),

View File

@@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import * as utils from "matrix-js-sdk/src/utils";
import { MatrixError, JoinRule, type Room, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixError, JoinRule, type Room, type MatrixEvent, type IJoinRoomOpts } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { type ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom";
@@ -512,15 +512,19 @@ export class RoomViewStore extends EventEmitter {
// take a copy of roomAlias & roomId as they may change by the time the join is complete
const { roomAlias, roomId = payload.roomId } = this.state;
const address = roomAlias || roomId!;
const viaServers = this.state.viaServers || [];
const joinOpts: IJoinRoomOpts = {
viaServers: this.state.viaServers || [],
...(payload.opts ?? {}),
};
if (SettingsStore.getValue("feature_share_history_on_invite")) {
joinOpts.acceptSharedHistory = true;
}
try {
const cli = MatrixClientPeg.safeGet();
await retry<Room, MatrixError>(
() =>
cli.joinRoom(address, {
viaServers,
...(payload.opts || {}),
}),
() => cli.joinRoom(address, joinOpts),
NUM_JOIN_RETRY,
(err) => {
// if we received a Gateway timeout or Cloudflare timeout then retry

View File

@@ -7,16 +7,7 @@ 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 {
filesize,
type FileSizeOptionsArray,
type FileSizeOptionsBase,
type FileSizeOptionsExponent,
type FileSizeOptionsObject,
type FileSizeOptionsString,
type FileSizeReturnArray,
type FileSizeReturnObject,
} from "filesize";
import { filesize, type FilesizeOptions, type FilesizeReturn } from "filesize";
import { type MediaEventContent } from "matrix-js-sdk/src/types";
import { _t } from "../languageHandler";
@@ -27,7 +18,7 @@ export function downloadLabelForFile(content: MediaEventContent, withSize = true
if (content.info?.size && withSize) {
// If we know the size of the file then add it as human-readable string to the end of the link text
// so that the user knows how big a file they are downloading.
text += " (" + <string>fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
text += " (" + fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
}
return text;
}
@@ -83,18 +74,11 @@ export function presentableTextForFile(
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferred
// from the file extension.
text += " (" + <string>fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
text += " (" + fileSize(content.info.size, { base: 2, standard: "jedec" }) + ")";
}
return text;
}
type FileSizeOptions =
| FileSizeOptionsString
| FileSizeOptionsBase
| FileSizeOptionsArray
| FileSizeOptionsExponent
| FileSizeOptionsObject;
/**
* wrapper function to set default values for filesize function
*
@@ -106,15 +90,7 @@ type FileSizeOptions =
* exponent: number;
* unit: string;}} formatted file size with unit e.g. 12kB, 12KB
*/
export function fileSize(byteCount: number, options: FileSizeOptionsString | FileSizeOptionsBase): string;
export function fileSize(byteCount: number, options: FileSizeOptionsArray): FileSizeReturnArray;
export function fileSize(byteCount: number, options: FileSizeOptionsExponent): number;
export function fileSize(byteCount: number, options: FileSizeOptionsObject): FileSizeReturnObject;
export function fileSize(byteCount: number): string;
export function fileSize(
byteCount: number,
options?: FileSizeOptions,
): string | number | FileSizeReturnArray | FileSizeReturnObject {
const defaultOption: FileSizeOptions = { base: 2, standard: "jedec", ...options };
return filesize(byteCount, defaultOption);
export function fileSize<O extends FilesizeOptions>(byteCount: number, options?: O): FilesizeReturn<O> {
const defaultOption = { base: 2, standard: "jedec", ...options } as O;
return filesize<O>(byteCount, defaultOption);
}

View File

@@ -6,7 +6,7 @@ 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 { MatrixError, type MatrixClient, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix";
import { MatrixError, type MatrixClient, EventType, type EmptyObject, type InviteOpts } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
@@ -183,7 +183,11 @@ export default class MultiInviter {
}
}
return this.matrixClient.invite(roomId, addr, this.reason);
const opts: InviteOpts = {};
if (this.reason !== undefined) opts.reason = this.reason;
if (SettingsStore.getValue("feature_share_history_on_invite")) opts.shareEncryptedHistory = true;
return this.matrixClient.invite(roomId, addr, opts);
} else {
throw new Error("Unsupported address");
}

View File

@@ -131,3 +131,23 @@ export async function waitForMember(
client.removeListener(RoomStateEvent.NewMember, handler);
});
}
/**
* Check if the user is the only admin in the room
* This function will *not* cause lazy loading of room members, so if these should be included then
* the caller needs to make sure members have been loaded.
* @param room The room to check if the user is the only admin.
* @returns True if the user is the only user with the highest power level, false otherwise
*/
export function isOnlyAdmin(room: Room): boolean {
const currentUserLevel = room.getMember(room.client.getSafeUserId())?.powerLevel;
const userLevelValues = room.getJoinedMembers().map((m) => m.powerLevel);
const maxUserLevel = Math.max(...userLevelValues.filter((x) => typeof x === "number"));
// If the user is the only user with highest power level
return (
maxUserLevel === currentUserLevel &&
userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel)
);
}

View File

@@ -270,11 +270,17 @@ describe("<MatrixChat />", () => {
// (must be sync otherwise the next test will start before it happens)
act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true));
// that will cause the Login to kick off an update in the background, which we need to allow to finish within
// an `act` to avoid warnings
await flushPromises();
localStorage.clear();
// This is a massive hack, but ...
//
// A lot of these tests end up completing while the login flow is still proceeding. So then, we start the next
// test while stuff is still ongoing from the previous test, which messes up the current test (by changing
// localStorage or opening modals, or whatever).
//
// There is no obvious event we could wait for which indicates that everything has completed, since each test
// does something different. Instead...
await act(() => sleep(200));
});
resetJsDomAfterEach();
@@ -769,6 +775,22 @@ describe("<MatrixChat />", () => {
),
).toBeInTheDocument();
});
it("should warn when user is the last admin", async () => {
jest.spyOn(room, "getMembers").mockReturnValue([
{ powerLevel: 100 } as unknown as MatrixJs.RoomMember,
{ powerLevel: 0 } as unknown as MatrixJs.RoomMember,
]);
jest.spyOn(room, "getMember").mockReturnValue({
powerLevel: 100,
} as unknown as MatrixJs.RoomMember);
dispatchAction();
await screen.findByRole("dialog");
expect(
screen.getByText(
"You're the only administrator in this room. If you leave, nobody will be able to change room settings or take other important actions.",
),
).toBeInTheDocument();
});
it("should do nothing on cancel", async () => {
dispatchAction();
const dialog = await screen.findByRole("dialog");
@@ -1613,7 +1635,7 @@ describe("<MatrixChat />", () => {
});
// Flaky test, see https://github.com/element-hq/element-web/issues/30337
it.skip("waits for other tab to stop during startup", async () => {
it("waits for other tab to stop during startup", async () => {
fetchMock.get("/welcome.html", { body: "<h1>Hello</h1>" });
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");

View File

@@ -50,6 +50,7 @@ exports[`<RoomAvatarView /> should render a low priority room decoration 1`] = `
</span>
<svg
aria-label="This is a low priority room"
aria-labelledby="«r0»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
@@ -92,6 +93,7 @@ exports[`<RoomAvatarView /> should render a public room decoration 1`] = `
</span>
<svg
aria-label="This room is public"
aria-labelledby="«rc»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@@ -134,6 +136,7 @@ exports[`<RoomAvatarView /> should render a video room decoration 1`] = `
</span>
<svg
aria-label="This room is a video room"
aria-labelledby="«r6»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"

View File

@@ -0,0 +1,50 @@
/*
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 { MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import LeaveSpaceDialog from "../../../../../src/components/views/dialogs/LeaveSpaceDialog";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
describe("LeaveSpaceDialog", () => {
it("should warn about not being able to rejoin non-public space", () => {
const mockClient = createTestClient();
const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
jest.spyOn(mockSpace.currentState, "getStateEvents").mockReturnValue(
new MatrixEvent({
type: "m.room.join_rules",
content: {
join_rule: "invite",
},
}),
);
render(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);
expect(screen.getByText(/You won't be able to rejoin unless you are re-invited/)).toBeInTheDocument();
});
it("should warn if user is the only admin", () => {
const mockClient = createTestClient();
const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
jest.spyOn(mockSpace, "getMembers").mockReturnValue([
{ powerLevel: 100 } as unknown as RoomMember,
{ powerLevel: 0 } as unknown as RoomMember,
]);
jest.spyOn(mockSpace, "getMember").mockReturnValue({
powerLevel: 100,
} as unknown as RoomMember);
render(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);
expect(
screen.getByText(/You're the only admin of this space. Leaving it will mean no one has control over it./),
).toBeInTheDocument();
});
});

View File

@@ -10,11 +10,13 @@ import React from "react";
import { mocked } from "jest-mock";
import { render, fireEvent, waitFor } from "jest-matrix-react";
import fetchMock from "fetch-mock-jest";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import ImageView from "../../../../../src/components/views/elements/ImageView";
import { FileDownloader } from "../../../../../src/utils/FileDownloader";
import Modal from "../../../../../src/Modal";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
import { stubClient } from "../../../../test-utils";
jest.mock("../../../../../src/utils/FileDownloader");
@@ -44,6 +46,39 @@ describe("<ImageView />", () => {
expect(fetchMock).toHaveFetched("https://example.com/image.png");
});
it("should use event as download source if given", async () => {
stubClient();
const event = new MatrixEvent({
event_id: "$eventId",
type: "m.image",
content: {
body: "fromEvent.png",
url: "mxc://test.dummy/fromEvent.png",
file_name: "filename.png",
},
origin_server_ts: new Date(2000, 0, 1, 0, 0, 0, 0).getTime(),
});
fetchMock.get("http://this.is.a.url/test.dummy/fromEvent.png", "TESTFILE");
const { getByRole } = render(
<ImageView
src="https://test.dummy/fromSrc.png"
name="fromName.png"
onFinished={jest.fn()}
mxEvent={event}
/>,
);
fireEvent.click(getByRole("button", { name: "Download" }));
await waitFor(() =>
expect(mocked(FileDownloader).mock.instances[0].download).toHaveBeenCalledWith({
blob: expect.anything(),
name: "fromEvent.png",
}),
);
expect(fetchMock).toHaveFetched("http://this.is.a.url/test.dummy/fromEvent.png");
});
it("should start download on Ctrl+S", async () => {
fetchMock.get("https://example.com/image.png", "TESTFILE");

View File

@@ -11,14 +11,39 @@ import { mocked } from "jest-mock";
import fetchMockJest from "fetch-mock-jest";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { stubClient } from "../../../../test-utils";
import { clearAllModals, stubClient } from "../../../../test-utils";
import DownloadActionButton from "../../../../../src/components/views/messages/DownloadActionButton";
import Modal from "../../../../../src/Modal";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })),
}));
describe("DownloadActionButton", () => {
const plainEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
beforeEach(() => {
jest.restoreAllMocks();
fetchMockJest.restore();
});
afterEach(() => {
clearAllModals();
});
it("should show error if media API returns one", async () => {
const cli = stubClient();
// eslint-disable-next-line no-restricted-properties
@@ -26,24 +51,14 @@ describe("DownloadActionButton", () => {
(mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`,
);
fetchMockJest.get("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Not found" },
});
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
const mediaEventHelper = new MediaEventHelper(event);
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => mediaEventHelper} />);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const spy = jest.spyOn(Modal, "createDialog");
@@ -57,4 +72,85 @@ describe("DownloadActionButton", () => {
),
);
});
it("should show download tooltip on hover", async () => {
stubClient();
const user = userEvent.setup();
fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE");
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => undefined} />);
const button = screen.getByRole("button");
await user.hover(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Download");
});
});
it("should show downloading tooltip while unencrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE");
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading");
});
});
it("should show decrypting tooltip while encrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF");
const e2eEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
file: { url: "mxc://matrix.org/1234" },
},
});
const mediaEventHelper = new MediaEventHelper(e2eEvent);
render(<DownloadActionButton mxEvent={e2eEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting");
});
});
});

View File

@@ -35,7 +35,9 @@ describe("MemberTileView", () => {
});
it("should not display an E2EIcon when the e2E status = normal", () => {
const { container } = render(<RoomMemberTileView member={member} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
const e2eIcon = container.querySelector(".mx_E2EIconView");
expect(e2eIcon).toBeNull();
expect(container).toMatchSnapshot();
@@ -47,7 +49,9 @@ describe("MemberTileView", () => {
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
@@ -68,7 +72,9 @@ describe("MemberTileView", () => {
crossSigningVerified: true,
} as DeviceVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
@@ -81,15 +87,21 @@ describe("MemberTileView", () => {
it("renders user labels correctly", async () => {
member.powerLevel = 50;
const { container: container1 } = render(<RoomMemberTileView member={member} />);
const { container: container1 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container1).toHaveTextContent("Moderator");
member.powerLevel = 100;
const { container: container2 } = render(<RoomMemberTileView member={member} />);
const { container: container2 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container2).toHaveTextContent("Admin");
member.isInvite = true;
const { container: container3 } = render(<RoomMemberTileView member={member} />);
const { container: container3 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container3).toHaveTextContent("Invited");
});
});
@@ -109,7 +121,14 @@ describe("MemberTileView", () => {
it("renders ThreePidInvite correctly", async () => {
const [{ threePidInvite }] = getPending3PidInvites(room);
const { container } = render(<ThreePidInviteTileView threePidInvite={threePidInvite!} />);
const { container } = render(
<ThreePidInviteTileView
threePidInvite={threePidInvite!}
memberIndex={0}
memberCount={1}
onFocus={jest.fn()}
/>,
);
expect(container).toMatchSnapshot();
});
});

View File

@@ -4,12 +4,15 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
aria-label="@userId:matrix.org"
aria-posinset="1"
aria-setsize="1"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="-1"
role="option"
tabindex="0"
>
<div
aria-hidden="true"
class="mx_MemberTileView_left"
>
<div
@@ -33,6 +36,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
>
<div
class="mx_DisambiguatedProfile"
title="@userId:matrix.org (@userId:matrix.org)"
>
<span
class=""
@@ -44,10 +48,11 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
</div>
</div>
<div
aria-hidden="true"
class="mx_MemberTileView_right"
>
<div
aria-labelledby="«ri»"
aria-labelledby="«r6»"
class="mx_E2EIconView"
>
<svg
@@ -73,12 +78,15 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
aria-label="@userId:matrix.org"
aria-posinset="1"
aria-setsize="1"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="-1"
role="option"
tabindex="0"
>
<div
aria-hidden="true"
class="mx_MemberTileView_left"
>
<div
@@ -102,6 +110,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
>
<div
class="mx_DisambiguatedProfile"
title="@userId:matrix.org (@userId:matrix.org)"
>
<span
class=""
@@ -113,10 +122,11 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
</div>
</div>
<div
aria-hidden="true"
class="mx_MemberTileView_right"
>
<div
aria-labelledby="«r8»"
aria-labelledby="«r0»"
class="mx_E2EIconView"
>
<svg
@@ -142,12 +152,15 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
aria-label="@userId:matrix.org"
aria-posinset="1"
aria-setsize="1"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="-1"
role="option"
tabindex="0"
>
<div
aria-hidden="true"
class="mx_MemberTileView_left"
>
<div
@@ -171,6 +184,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
>
<div
class="mx_DisambiguatedProfile"
title="@userId:matrix.org (@userId:matrix.org)"
>
<span
class=""
@@ -182,6 +196,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
</div>
</div>
<div
aria-hidden="true"
class="mx_MemberTileView_right"
/>
</div>
@@ -193,11 +208,15 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
<div>
<div>
<div
aria-label="Foobar"
aria-posinset="1"
aria-setsize="1"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="-1"
role="option"
tabindex="0"
>
<div
aria-hidden="true"
class="mx_MemberTileView_left"
>
<div
@@ -223,6 +242,7 @@ exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly
</div>
</div>
<div
aria-hidden="true"
class="mx_MemberTileView_right"
>
<div

View File

@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import React, { act } from "react";
import { render, type RenderResult, waitFor } from "jest-matrix-react";
import { VirtuosoMockContext } from "react-virtuoso";
import {
Room,
type MatrixClient,
@@ -121,6 +122,13 @@ export async function renderMemberList(
<MemberListView roomId={memberListRoom.roomId} onClose={() => {}} />
</SDKContext.Provider>
</MatrixClientContext.Provider>,
{
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 56 }}>
{children}
</VirtuosoMockContext.Provider>
),
},
);
await waitFor(async () => {
expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3);

View File

@@ -0,0 +1,453 @@
/*
Copyright 2024 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, fireEvent } from "jest-matrix-react";
import { VirtuosoMockContext } from "react-virtuoso";
import { ListView, type IListViewProps } from "../../../../../src/components/utils/ListView";
interface TestItem {
id: string;
name: string;
isFocusable?: boolean;
}
const SEPARATOR_ITEM = "SEPARATOR" as const;
type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM;
describe("ListView", () => {
const mockOnSelectItem = jest.fn();
const mockGetItemComponent = jest.fn();
const mockIsItemFocusable = jest.fn();
const defaultItems: TestItemWithSeparator[] = [
{ id: "1", name: "Item 1" },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2" },
{ id: "3", name: "Item 3" },
];
const defaultProps: IListViewProps<TestItemWithSeparator, any> = {
items: defaultItems,
onSelectItem: mockOnSelectItem,
getItemComponent: mockGetItemComponent,
isItemFocusable: mockIsItemFocusable,
getItemKey: (item) => (typeof item === "string" ? item : item.id),
};
const getListViewComponent = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => {
const mergedProps = { ...defaultProps, ...props };
return <ListView {...mergedProps} role="grid" aria-rowcount={props.items?.length} aria-colcount={1} />;
};
const renderListViewWithHeight = (props: Partial<IListViewProps<TestItemWithSeparator, any>> = {}) => {
const mergedProps = { ...defaultProps, ...props };
return render(getListViewComponent(mergedProps), {
wrapper: ({ children }) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 400, itemHeight: 56 }}>
{children}
</VirtuosoMockContext.Provider>
),
});
};
beforeEach(() => {
jest.clearAllMocks();
mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div className="mx_item" data-testid={`row-${index}`} tabIndex={isFocused ? 0 : -1}>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
});
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("Rendering", () => {
it("should render the ListView component", () => {
renderListViewWithHeight();
expect(screen.getByRole("grid")).toBeInTheDocument();
});
it("should render with empty items array", () => {
renderListViewWithHeight({ items: [] });
expect(screen.getByRole("grid")).toBeInTheDocument();
});
});
describe("Keyboard Navigation", () => {
it("should handle Enter key and call onSelectItem when focused", async () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
// Focus to activate the list and navigate to first focusable item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "Enter" });
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
});
it("should handle Space key and call onSelectItem when focused", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
expect(container.querySelectorAll(".mx_item")).toHaveLength(4);
// Focus to activate the list and navigate to first focusable item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "Space" });
expect(mockOnSelectItem).toHaveBeenCalledWith(defaultItems[0]);
});
it("should handle ArrowDown key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// ArrowDown should skip the non-focusable item at index 1 and go to index 2
const items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0");
expect(items[0]).toHaveAttribute("tabindex", "-1");
expect(items[1]).toHaveAttribute("tabindex", "-1");
});
it("should handle ArrowUp key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate down to second item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then navigate back up
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify focus moved back to first item
const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0");
expect(items[1]).toHaveAttribute("tabindex", "-1");
});
it("should handle Home key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to a later item
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
fireEvent.keyDown(container, { code: "ArrowDown" });
// Then press Home to go to first item
fireEvent.keyDown(container, { code: "Home" });
// Verify focus moved to first item
const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0");
// Check that other items are not focused
for (let i = 1; i < items.length; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1");
}
});
it("should handle End key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
fireEvent.focus(container);
// Then press End to go to last item
fireEvent.keyDown(container, { code: "End" });
// Verify focus moved to last visible item
const items = container.querySelectorAll(".mx_item");
// Should focus on the last visible item
const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "0");
// Check that other items are not focused
for (let i = 0; i < lastIndex; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1");
}
});
it("should handle PageDown key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// First focus on the list (starts at first item)
fireEvent.focus(container);
// Then press PageDown to jump down by viewport size
fireEvent.keyDown(container, { code: "PageDown" });
// Verify focus moved down
const items = container.querySelectorAll(".mx_item");
// PageDown should move to the last visible item since we only have 4 items
const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "0");
expect(items[0]).toHaveAttribute("tabindex", "-1");
});
it("should handle PageUp key navigation", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// First focus and navigate to last item to have something to page up from
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "End" });
// Then press PageUp to jump up by viewport size
fireEvent.keyDown(container, { code: "PageUp" });
// Verify focus moved up
const items = container.querySelectorAll(".mx_item");
// PageUp should move back to the first item since we only have 4 items
expect(items[0]).toHaveAttribute("tabindex", "0");
const lastIndex = items.length - 1;
expect(items[lastIndex]).toHaveAttribute("tabindex", "-1");
});
it("should skip non-focusable items when navigating down", async () => {
// Create items where every other item is not focusable
const mixedItems = [
{ id: "1", name: "Item 1", isFocusable: true },
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
SEPARATOR_ITEM,
{ id: "4", name: "Item 4", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListViewWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify it skipped the non-focusable item at index 1
// and went directly to the focusable item at index 2
const items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // Item 3 is focused
expect(items[0]).toHaveAttribute("tabindex", "-1"); // Item 1 is not focused
expect(items[1]).toHaveAttribute("tabindex", "-1"); // Item 2 (non-focusable) is not focused
});
it("should skip non-focusable items when navigating up", () => {
const mixedItems = [
{ id: "1", name: "Item 1", isFocusable: true },
SEPARATOR_ITEM,
{ id: "2", name: "Item 2", isFocusable: false },
{ id: "3", name: "Item 3", isFocusable: true },
];
mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => {
if (item === SEPARATOR_ITEM) return false;
return (item as TestItem).isFocusable !== false;
});
renderListViewWithHeight({ items: mixedItems });
const container = screen.getByRole("grid");
// Focus and go to last item first, then navigate up
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "End" });
fireEvent.keyDown(container, { code: "ArrowUp" });
// Verify it skipped non-focusable items
// and went to the first focusable item
const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0"); // Item 1 is focused
expect(items[3]).toHaveAttribute("tabindex", "-1"); // Item 3 is not focused anymore
});
});
describe("Focus Management", () => {
it("should focus first item when list gains focus for the first time", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// Initial focus should go to first item
fireEvent.focus(container);
// Verify first item gets focus
const items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0");
// Other items should not be focused
for (let i = 1; i < items.length; i++) {
expect(items[i]).toHaveAttribute("tabindex", "-1");
}
});
it("should restore last focused item when regaining focus", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// Focus and navigate to simulate previous usage
fireEvent.focus(container);
fireEvent.keyDown(container, { code: "ArrowDown" });
// Verify item 2 is focused
let items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // ArrowDown skips to item 2
// Simulate blur by focusing elsewhere
fireEvent.blur(container);
// Regain focus should restore last position
fireEvent.focus(container);
// Verify focus is restored to the previously focused item
items = container.querySelectorAll(".mx_item");
expect(items[2]).toHaveAttribute("tabindex", "0"); // Should still be item 2
});
it("should not interfere with focus if item is already focused", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
// Focus once
fireEvent.focus(container);
// Focus again when already focused
fireEvent.focus(container);
expect(container).toBeInTheDocument();
});
it("should not scroll to top when clicking an item after manual scroll", () => {
// Create a larger list to enable meaningful scrolling
const largerItems = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
const mockOnClick = jest.fn();
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: any, onFocus: (e: React.FocusEvent) => void) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
onClick={() => mockOnClick(item)}
onFocus={onFocus}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
},
);
const { container } = renderListViewWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
fireEvent.focus(listContainer);
// Verify first item is focused initially and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0");
expect(items[0]).toHaveAttribute("data-testid", "row-0");
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
// This changes which items are visible but DOES NOT change tabIndexKey
// tabIndexKey should still point to "item-0" but "item-0" is no longer visible
fireEvent.scroll(listContainer, { target: { scrollTop: 300 } });
// Step 3: After scrolling, different items should now be visible
// but tabIndexKey should still point to "item-0" (which is no longer visible)
items = container.querySelectorAll(".mx_item");
// Verify that item-0 is no longer in the DOM (because it's scrolled out of view)
const item0 = container.querySelector("[data-testid='row-0']");
expect(item0).toBeNull();
// Find a visible item to click on (should be items from further down the list)
const visibleItems = container.querySelectorAll(".mx_item");
expect(visibleItems.length).toBeGreaterThan(0);
const clickTargetItem = visibleItems[0]; // Click on the first visible item
// Click on the visible item
fireEvent.click(clickTargetItem);
// The click should trigger the onFocus callback, which updates the tabIndexKey
// This simulates the real user interaction where clicking an item focuses it
fireEvent.focus(clickTargetItem);
// Verify the click was handled
expect(mockOnClick).toHaveBeenCalled();
// With the fix applied: the clicked item should become focused (tabindex="0")
// This validates that the fix prevents unwanted scrolling back to the top
expect(clickTargetItem).toHaveAttribute("tabindex", "0");
// The key validation: ensure we haven't scrolled back to the top
// item-0 should still not be visible (if the fix is working)
const item0AfterClick = container.querySelector("[data-testid='row-0']");
expect(item0AfterClick).toBeNull();
});
});
describe("Accessibility", () => {
it("should set correct ARIA attributes", () => {
renderListViewWithHeight();
const container = screen.getByRole("grid");
expect(container).toHaveAttribute("role", "grid");
expect(container).toHaveAttribute("aria-rowcount", "4");
expect(container).toHaveAttribute("aria-colcount", "1");
});
it("should update aria-rowcount when items change", () => {
const { rerender } = renderListViewWithHeight();
let container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-rowcount", "4");
// Update with fewer items
const fewerItems = [
{ id: "1", name: "Item 1" },
{ id: "2", name: "Item 2" },
];
rerender(
getListViewComponent({
...defaultProps,
items: fewerItems,
}),
);
container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-rowcount", "2");
});
it("should handle custom ARIA label", () => {
renderListViewWithHeight({ "aria-label": "Custom list label" });
const container = screen.getByRole("grid");
expect(container).toHaveAttribute("aria-label", "Custom list label");
});
});
});

View File

@@ -440,6 +440,17 @@ describe("RoomViewStore", function () {
});
expect(mocked(dis.dispatch).mock.calls[2][0]).toEqual({ action: "prompt_ask_to_join" });
});
it("sets 'acceptSharedHistory' if that option is enabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => {
return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled.
});
dis.dispatch({ action: Action.ViewRoom, room_id: roomId });
dis.dispatch({ action: Action.JoinRoom });
await untilDispatch(Action.JoinRoomReady, dis);
expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { acceptSharedHistory: true, viaServers: [] });
});
});
describe("Action.JoinRoomError", () => {

View File

@@ -96,9 +96,9 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {});
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {});
expectAllInvitedResult(result);
});
@@ -114,9 +114,9 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {});
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {});
expectAllInvitedResult(result);
});
@@ -129,7 +129,7 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(1);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
// The resolved state is 'invited' for all users.
// With the above client expectations, the test ensures that only the first user is invited.
@@ -231,5 +231,15 @@ describe("MultiInviter", () => {
`"This space is unfederated. You cannot invite people from external servers."`,
);
});
it("should set shareEncryptedHistory if that setting is enabled", async () => {
mocked(SettingsStore.getValue).mockImplementation((settingName, roomId, value) => {
return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled.
});
await inviter.invite([MXID1]);
expect(client.invite).toHaveBeenCalledTimes(1);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, { shareEncryptedHistory: true });
});
});
});

View File

@@ -1687,10 +1687,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5"
integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g==
"@element-hq/element-web-playwright-common@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.3.tgz#c33217032e805a0668fbf3fa09929aac9acedb09"
integrity sha512-WrvScEsXTBreYmOMK2AiAA/ifAbgOrctolex2LRO0Z0TUkDF5Bh2sg6MBTK8i11EO+ifsy2eCLJtAQ//Yzj1GA==
"@element-hq/element-web-playwright-common@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.4.tgz#d58dba7b5b4198f2fc137e1bdd1ad82c2cee46fb"
integrity sha512-QnWz8dlRuQHZYZT9ewrcN++l7gQ0Kf+oZwMCi0k1TBf8Za40r5ibNrgZqZYyCoItBc8LGTVL3yOrUfzN4Dm2Qw==
dependencies:
"@axe-core/playwright" "^4.10.1"
"@testcontainers/postgresql" "^11.0.0"
@@ -2463,10 +2463,10 @@
emojibase "^15.3.1"
emojibase-data "^15.3.1"
"@matrix-org/matrix-sdk-crypto-wasm@^15.0.0":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.0.0.tgz#5b29ca1c62f3aface9db06d7441d0a9ba2cd3439"
integrity sha512-tzBGf/jugrOw190Na77LljZIQMTSL6SAnZaATKMlb2j1XOfc5Q+bSJTb9ZWBR7TFs0d8K9spcwRHPc4S/7CMYw==
"@matrix-org/matrix-sdk-crypto-wasm@^15.1.0":
version "15.1.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.1.0.tgz#653956f5f6daced55a9df3d2c1114eb2c017b528"
integrity sha512-ZsDdjn46J3+VxsDLmaSODuS+qtGZB/i3Cg9tWL1QPNjvAWzNaTHQ7glleByI2PKVBm83aklfuhGKT2MqE1ZsEA==
"@matrix-org/react-sdk-module-api@^2.4.0":
version "2.5.0"
@@ -4543,7 +4543,7 @@
classnames "^2.5.1"
vaul "^1.0.0"
"@vector-im/matrix-wysiwyg-wasm@link:../../bindings/wysiwyg-wasm":
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
uid ""
@@ -8108,10 +8108,10 @@ filelist@^1.0.4:
dependencies:
minimatch "^5.0.1"
filesize@10.1.6:
version "10.1.6"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361"
integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==
filesize@11.0.2:
version "11.0.2"
resolved "https://registry.yarnpkg.com/filesize/-/filesize-11.0.2.tgz#b7771e3836812582ad74b8a10d6eb0dc58c1ceda"
integrity sha512-s/iAeeWLk5BschUIpmdrF8RA8lhFZ/xDZgKw1Tan72oGws1/dFGB06nYEiyyssWUfjKNQTNRlrwMVjO9/hvXDw==
fill-range@^7.1.1:
version "7.1.1"
@@ -10510,20 +10510,20 @@ linkify-it@^4.0.1:
dependencies:
uc.micro "^1.0.1"
linkify-react@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.3.1.tgz#0655632d654a881e54d955ec12b1ab817d879f50"
integrity sha512-w8ahBdCwF9C/doS4V3nE93QF1oyORmosvi8UEUbpHYws077eGzhkxUzJQcE2/SU5Q2K7SD80M4ybwwZGHErx5Q==
linkify-react@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.3.2.tgz#8d47fb0ad96ab5b38c07bfbebdcbc57794430693"
integrity sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==
linkify-string@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-4.3.1.tgz#d6f8b7166d588a64943e3bb23302ce44047f61a2"
integrity sha512-1AnH52wZwuJi+skG/9dUphhQEUblVGSf0ntkM8z21RS9bF7xR0qPpqnNTyCo2Obqs5MR5wi8y5wOLPoBbzxm2w==
linkify-string@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-4.3.2.tgz#535f7a3c25a8c83b862aa3263d6cb09fd4e4b3f4"
integrity sha512-JqBuQpSa+CSj2tskIII70SKOjPfjXwDFyjRRNFTrlg76gp2nap36xeRj/cWaXxukqBNrxM+L07XyKRsUtH/DpQ==
linkifyjs@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.1.tgz#1f246ebf4be040002accd1f4535b6af7c7e37898"
integrity sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==
linkifyjs@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
lint-staged@^16.0.0:
version "16.1.2"
@@ -10846,11 +10846,11 @@ matrix-events-sdk@0.0.1:
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "37.11.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f8f1bf38373a944f12a739a301c1770c7bf08265"
version "37.12.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/56b24c0bdc3e1c6b9778dffa5cab7959848f4e0e"
dependencies:
"@babel/runtime" "^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm" "^15.0.0"
"@matrix-org/matrix-sdk-crypto-wasm" "^15.1.0"
another-json "^0.2.0"
bs58 "^6.0.0"
content-type "^1.0.4"
@@ -13091,6 +13091,11 @@ react-virtualized@^9.22.5:
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react-virtuoso@^4.12.6:
version "4.12.6"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.6.tgz#20fe374d43cce3c9821e29f4cc4d050596d06d01"
integrity sha512-bfvS6aCL1ehXmq39KRiz/vxznGUbtA27I5I24TYCe1DhMf84O3aVNCIwrSjYQjkJGJGzY46ihdN8WkYlemuhMQ==
"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^19.0.0:
version "19.1.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"