mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-09 01:30:57 +00:00
Compare commits
35 Commits
robin/depr
...
dbkr/hydra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76abd064db | ||
|
|
bc2cb7ac4c | ||
|
|
f646164540 | ||
|
|
c63e6193a6 | ||
|
|
56b34de568 | ||
|
|
dd0b7a9fff | ||
|
|
c42ba2a8c2 | ||
|
|
7faee3d1b7 | ||
|
|
30e7567064 | ||
|
|
2250f5e6a2 | ||
|
|
e43b696461 | ||
|
|
bf98ede4fa | ||
|
|
ddbf88a7b0 | ||
|
|
3eca1de385 | ||
|
|
cc0ece9837 | ||
|
|
ab6ef2fa85 | ||
|
|
c79c8c836b | ||
|
|
3f0dcaa64c | ||
|
|
652e891663 | ||
|
|
7eb5a29cf0 | ||
|
|
1b38624fd8 | ||
|
|
d98533025a | ||
|
|
c3e5367e45 | ||
|
|
1e15a322a5 | ||
|
|
452996eacf | ||
|
|
ee120f2fa9 | ||
|
|
94aa51dc57 | ||
|
|
e19d3dcd44 | ||
|
|
5a4b5418cc | ||
|
|
d1f62317ba | ||
|
|
9232a220dc | ||
|
|
45a2fd9d63 | ||
|
|
db5c69e228 | ||
|
|
acb3d31a07 | ||
|
|
9136332f42 |
13
.github/workflows/docker.yaml
vendored
13
.github/workflows/docker.yaml
vendored
@@ -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'] }}"
|
||||
}
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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
|
||||
|
||||
13
package.json
13
package.json
@@ -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",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
291
src/components/utils/ListView.tsx
Normal file
291
src/components/utils/ListView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
453
test/unit-tests/components/views/utils/ListView-test.tsx
Normal file
453
test/unit-tests/components/views/utils/ListView-test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
61
yarn.lock
61
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user