diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 9eca8c8636..0b887ecc77 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -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'] }}" + } diff --git a/.storybook/main.ts b/.storybook/main.ts index ef8b8cd19c..fc4daac88a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -31,6 +31,9 @@ const config: StorybookConfig = { }, // Needed for counterpart to work plugins: [nodePolyfills({ include: ["process", "util"] })], + server: { + allowedHosts: ["localhost", ".docker.internal"], + }, }); }, }; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 8d7516680a..b7f05badf3 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -5,7 +5,8 @@ import "../res/css/shared.pcss"; import "./preview.css"; import React, { useLayoutEffect } from "react"; import { FORCE_RE_RENDER } from "storybook/internal/core-events"; -import { setLanguage } from "../src/shared-components/i18n"; +import { setLanguage } from "../src/shared-components/utils/i18n"; +import { TooltipProvider } from "@vector-im/compound-web"; export const globalTypes = { theme: { @@ -82,9 +83,24 @@ export const withLanguageProvider: Decorator = (Story, context) => { ); }; +const withTooltipProvider: Decorator = (Story) => { + return ( + + + + ); +}; + const preview: Preview = { tags: ["autodocs"], - decorators: [withThemeProvider, withLanguageProvider], + decorators: [withThemeProvider, withLanguageProvider, withTooltipProvider], + parameters: { + options: { + storySort: { + method: "alphabetical", + }, + }, + }, }; export default preview; diff --git a/CHANGELOG.md b/CHANGELOG.md index 36dea524a0..01fa572689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +Changes in [1.11.109](https://github.com/element-hq/element-web/releases/tag/v1.11.109) (2025-08-11) +==================================================================================================== +This release supports the upcoming v12 ("hydra") Matrix room version and is necessary to view and participate in these rooms. + +## ✨ Features + +* [Backport staging] Allow /upgraderoom command without developer mode enabled ([#30529](https://github.com/element-hq/element-web/pull/30529)). Contributed by @RiotRobot. +* [Backport staging] Support for creator/owner power level ([#30526](https://github.com/element-hq/element-web/pull/30526)). Contributed by @RiotRobot. +* New room list: change icon and label of menu item for to start a DM ([#30470](https://github.com/element-hq/element-web/pull/30470)). Contributed by @florianduros. +* Implement the member list with virtuoso ([#29869](https://github.com/element-hq/element-web/pull/29869)). Contributed by @langleyd. +* Add labs option for history sharing on invite ([#30313](https://github.com/element-hq/element-web/pull/30313)). Contributed by @richvdh. +* Bump wysiwyg to 2.39.0 adding support for pasting rich text content in the Rich Text Edtior ([#30421](https://github.com/element-hq/element-web/pull/30421)). Contributed by @langleyd. +* Support `EventShieldReason.MISMATCHED_SENDER` ([#30403](https://github.com/element-hq/element-web/pull/30403)). Contributed by @richvdh. +* Change unencrypted and public pills to blue ([#30399](https://github.com/element-hq/element-web/pull/30399)). Contributed by @florianduros. +* Change color of public room icon ([#30390](https://github.com/element-hq/element-web/pull/30390)). Contributed by @florianduros. +* Script for updating storybook screenshots ([#30340](https://github.com/element-hq/element-web/pull/30340)). Contributed by @dbkr. +* Add toggle to hide empty state in devtools ([#30352](https://github.com/element-hq/element-web/pull/30352)). Contributed by @toger5. + +## 🐛 Bug Fixes + +* [Backport staging] Use userId to filter users in non-federated rooms when showing the InviteDialog ([#30537](https://github.com/element-hq/element-web/pull/30537)). Contributed by @RiotRobot. +* [Backport staging] Catch error when encountering invalid m.room.pinned\_events event ([#30536](https://github.com/element-hq/element-web/pull/30536)). Contributed by @RiotRobot. +* Update for compatibility with v12 rooms ([#30452](https://github.com/element-hq/element-web/pull/30452)). Contributed by @dbkr. +* New room list: fix tooltip on presence ([#30474](https://github.com/element-hq/element-web/pull/30474)). Contributed by @florianduros. +* New room list: add tooltip for presence and room status ([#30472](https://github.com/element-hq/element-web/pull/30472)). Contributed by @florianduros. +* Fix: Clicking on an item in the member list causes it to scroll to the top rather than show the profile view ([#30455](https://github.com/element-hq/element-web/pull/30455)). Contributed by @langleyd. +* Put the 'decrypting' tooltip back ([#30446](https://github.com/element-hq/element-web/pull/30446)). Contributed by @dbkr. +* Use server name explicitly for via. ([#30362](https://github.com/element-hq/element-web/pull/30362)). Contributed by @Half-Shot. +* fix: replace hardcoded string in poll history dialog ([#30402](https://github.com/element-hq/element-web/pull/30402)). Contributed by @florianduros. +* fix: replace hardcoded string on qr code back button ([#30401](https://github.com/element-hq/element-web/pull/30401)). Contributed by @florianduros. +* Fix color of icon button with outline ([#30361](https://github.com/element-hq/element-web/pull/30361)). Contributed by @florianduros. + + Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30) ==================================================================================================== ## 🐛 Bug Fixes diff --git a/Dockerfile b/Dockerfile index 63dc31b4f3..ff9ec91f0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5 # Builder -FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder +FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:a80324457a2c8d09c83ff9edf2bdf71f378d3288de920e68a358bd3c484b8c4a AS builder # Support custom branch of the js-sdk. This also helps us build images of element-web develop. ARG USE_CUSTOM_SDKS=false @@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh RUN cp /src/config.sample.json /src/webapp/config.json # App -FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00 +FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:86df552d36eb24c45d3f5becf6423bd056a3fd235d7085fe3d5ea28ba89a8232 # Need root user to install packages & manipulate the usr directory USER root diff --git a/jest.config.ts b/jest.config.ts index f40ac487a2..459bcf5f08 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -22,6 +22,8 @@ const config: Config = { setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"], setupFilesAfterEnv: ["/test/setupTests.ts"], moduleNameMapper: { + // Support CSS module + "\\.(module.css)$": "identity-obj-proxy", "\\.(css|scss|pcss)$": "/__mocks__/cssMock.js", "\\.(gif|png|ttf|woff2)$": "/__mocks__/imageMock.js", "\\.svg$": "/__mocks__/svg.js", diff --git a/package.json b/package.json index 120631e453..1142153445 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.11.108", + "version": "1.11.109", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { @@ -69,11 +69,12 @@ "storybook": "storybook dev -p 6007", "build-storybook": "storybook build", "test:storybook": "test-storybook --url http://localhost:6007/", - "test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"" + "test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook --no-open\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\"", + "test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot" }, "resolutions": { "**/pretty-format/react-is": "19.1.0", - "@playwright/test": "1.53.2", + "@playwright/test": "1.54.1", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "oidc-client-ts": "3.3.0", @@ -98,7 +99,7 @@ "@types/react-virtualized": "^9.21.30", "@vector-im/compound-design-tokens": "^5.0.0", "@vector-im/compound-web": "^8.1.2", - "@vector-im/matrix-wysiwyg": "2.38.4", + "@vector-im/matrix-wysiwyg": "2.39.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", @@ -116,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", @@ -127,14 +128,14 @@ "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", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "37.12.0", + "matrix-js-sdk": "37.13.0", "matrix-widget-api": "^1.10.0", "memoize-one": "^6.0.0", "mime": "^4.0.4", @@ -142,7 +143,7 @@ "opus-recorder": "^8.0.3", "pako": "^2.0.3", "png-chunks-extract": "^1.0.0", - "posthog-js": "1.256.2", + "posthog-js": "1.257.0", "qrcode": "1.5.4", "re-resizable": "6.11.2", "react": "^19.0.0", @@ -153,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", @@ -185,12 +187,12 @@ "@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.2", + "@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", "@rrweb/types": "^2.0.0-alpha.18", - "@sentry/webpack-plugin": "^3.0.0", + "@sentry/webpack-plugin": "^4.0.0", "@storybook/addon-designs": "^10.0.1", "@storybook/addon-docs": "^9.0.12", "@storybook/icons": "^1.4.0", @@ -264,6 +266,7 @@ "file-loader": "^6.0.0", "html-webpack-plugin": "^5.5.3", "husky": "^9.0.0", + "identity-obj-proxy": "^3.0.0", "jest": "^29.6.2", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index f325754326..eb21dfc909 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -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 }) => { diff --git a/playwright/e2e/lazy-loading/lazy-loading.spec.ts b/playwright/e2e/lazy-loading/lazy-loading.spec.ts index 7c31c288fa..f6f098a079 100644 --- a/playwright/e2e/lazy-loading/lazy-loading.spec.ts +++ b/playwright/e2e/lazy-loading/lazy-loading.spec.ts @@ -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 }); diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts index daa8d3869f..ed7a24fff8 100644 --- a/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts +++ b/playwright/e2e/left-panel/room-list-panel/room-list-header.spec.ts @@ -35,8 +35,8 @@ test.describe("Header section of the room list", () => { await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png"); - // New message should open the direct messages dialog - await page.getByRole("menuitem", { name: "New message" }).click(); + // Start chat should open the direct messages dialog + await page.getByRole("menuitem", { name: "Start chat" }).click(); await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible(); await app.closeDialog(); diff --git a/playwright/e2e/right-panel/memberlist.spec.ts b/playwright/e2e/right-panel/memberlist.spec.ts index cd22626575..71a4a3cada 100644 --- a/playwright/e2e/right-panel/memberlist.spec.ts +++ b/playwright/e2e/right-panel/memberlist.spec.ts @@ -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 { + 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(); + }); }); diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts index 58574a46ff..a77e89fcdc 100644 --- a/playwright/e2e/share-dialog/share-dialog.spec.ts +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -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" }); diff --git a/playwright/e2e/spaces/spaces.spec.ts b/playwright/e2e/spaces/spaces.spec.ts index 56c8ae22b8..b3f7ecb0c9 100644 --- a/playwright/e2e/spaces/spaces.spec.ts +++ b/playwright/e2e/spaces/spaces.spec.ts @@ -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"); diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png index 45d2a775ea..d47d04e9a6 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/default-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png index fc391d56cf..ac3f26e529 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-filter-sort.spec.ts/room-panel-empty-room-list-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png index 9f501a58d4..202a83c23a 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-header.spec.ts/room-list-header-compose-menu-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png index 75cea916c9..1e0add7b56 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png index e023a0d34d..0e363b3747 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png index 123cf37586..df3e924e4c 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list-search.spec.ts/search-section-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index e9f1db7361..511d6b246b 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index b638b2880e..85b47b0f12 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 4307750787..b802eb7888 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png index c75c1d08cc..65683ec463 100644 Binary files a/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png and b/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-public-linux.png differ diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png index 78ba6e5cfd..3e78322813 100644 Binary files a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png and b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-All-new-pinned-messages-linux.png differ diff --git a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png index a5db88aae6..ab5b0ea76c 100644 Binary files a/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png and b/playwright/snapshots/right-panel/memberlist.spec.ts/with-four-members-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png index 8ba6f98e11..0645b780b8 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png index 471c26ccdb..c872720f66 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-with-icon-linux.png differ diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts index 712634e8a4..6c8962abf2 100644 --- a/playwright/testcontainers/synapse.ts +++ b/playwright/testcontainers/synapse.ts @@ -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:6e8c604472c7cf13c30f06791c4b3368cf433956b3973e97cd978ec680f4b62f"; +const TAG = "develop@sha256:2f6fff14ff23f356705abdbf2ed62c3dd6ca2103cef4ae813714ddc199bbd76a"; /** * SynapseContainer which freezes the docker digest to stabilise tests, diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b68da87167..bd002fb80c 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -53,8 +53,6 @@ @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/typography/_Caption.pcss"; -@import "./components/views/utils/_Box.pcss"; -@import "./components/views/utils/_Flex.pcss"; @import "./compound/_Icon.pcss"; @import "./compound/_SuccessDialog.pcss"; @import "./structures/_AutoHideScrollbar.pcss"; diff --git a/res/css/structures/ErrorView.pcss b/res/css/structures/ErrorView.pcss index ddc510e188..805456bdbb 100644 --- a/res/css/structures/ErrorView.pcss +++ b/res/css/structures/ErrorView.pcss @@ -50,7 +50,7 @@ Please see LICENSE files in the repository root for full details. color: var(--cpd-color-text-secondary); } - .mx_Flex { + .mx_ErrorView_flexContainer { margin: 0 auto; max-width: max-content; flex-wrap: wrap; diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index 3c1c07677c..96d1b0867e 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -32,7 +32,7 @@ Please see LICENSE files in the repository root for full details. padding: 0 12px; color: var(--cpd-color-text-secondary); - .mx_Box { + .mx_RoomSummaryCard_topic_box { width: 100%; } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index 3030b93c03..2b6480bd84 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -108,11 +108,6 @@ Please see LICENSE files in the repository root for full details. margin: 0; font-size: $font-20px; line-height: $font-25px; - - /* E2E icon wrapper */ - .mx_Flex > span { - display: inline-block; - } } .mx_UserInfo_profile_name { diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss index f6f98e1efe..472badc3ad 100644 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss @@ -12,10 +12,6 @@ border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); padding: 0 var(--cpd-space-3x); - svg { - fill: var(--cpd-color-icon-secondary); - } - .mx_RoomListSearch_search { /* The search button should take all the remaining space */ flex: 1; @@ -23,6 +19,10 @@ color: var(--cpd-color-text-secondary); min-width: 0; + svg { + fill: var(--cpd-color-icon-secondary); + } + span { flex: 1; @@ -42,10 +42,4 @@ } } } - - .mx_RoomListSearch_button:hover { - svg { - fill: var(--cpd-color-icon-primary); - } - } } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 20e7435599..dcd4ae9c00 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -621,6 +621,9 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean } await getStoredSessionVars(); if (hasAccessToken && !accessToken) { + logger.warn( + "restoreSessionFromStorage: storage indicates we should have an access token, but we do not. Displaying StorageEvictedDialog", + ); await abortLogin(); } @@ -823,6 +826,7 @@ async function doSetLoggedIn( // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { + logger.warn("doSetLoggedIn: StorageManager consistency check failed; displaying StorageEvictedDialog."); await abortLogin(); } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index ed2382c3ff..f0d9085507 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -148,7 +148,7 @@ export const Commands = [ command: "upgraderoom", args: "", description: _td("slash_command|upgraderoom"), - isEnabled: (cli) => !isCurrentLocalRoom(cli) && SettingsStore.getValue("developerMode"), + isEnabled: (cli) => !isCurrentLocalRoom(cli), runFn: function (cli, roomId, threadId, args) { if (args) { const room = cli.getRoom(roomId); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index b63e5b2a00..439a079953 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -572,8 +572,11 @@ function textForPinnedEvent(event: MatrixEvent, client: MatrixClient, allowJSX: const senderName = getSenderName(event); const roomId = event.getRoomId()!; - const pinned = event.getContent<{ pinned: string[] }>().pinned ?? []; - const previouslyPinned: string[] = event.getPrevContent().pinned ?? []; + const content = event.getContent<{ pinned: string[] }>(); + const prevContent = event.getPrevContent(); + + const pinned = Array.isArray(content.pinned) ? content.pinned : []; + const previouslyPinned: string[] = Array.isArray(prevContent.pinned) ? prevContent.pinned : []; const newlyPinned = pinned.filter((item) => previouslyPinned.indexOf(item) < 0); const newlyUnpinned = previouslyPinned.filter((item) => pinned.indexOf(item) < 0); diff --git a/src/Unread.ts b/src/Unread.ts index e8f4769e25..d6a80a8f97 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -39,7 +39,12 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent): } if (ev.isRedacted()) return false; - return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */); + try { + return haveRendererForEvent(ev, client, false /* hidden messages should never trigger unread counts anyways */); + } catch (e) { + console.warn("Error determining if event should trigger unread count", e); + return false; // If we can't determine if the event should trigger an unread count, assume it does not. + } } export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean { diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index a1b18a106c..21ea5abb02 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. */ // Import i18n.tsx instead of languageHandler to avoid circular deps -import { _td, type TranslationKey } from "../shared-components/i18n"; +import { _td, type TranslationKey } from "../shared-components/utils/i18n"; import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard"; import { type IBaseSetting } from "../settings/Settings"; import { type KeyCombo } from "../KeyBindingsManager"; diff --git a/src/async-components/structures/ErrorView.tsx b/src/async-components/structures/ErrorView.tsx index 0f9a61781e..5ce2a900a1 100644 --- a/src/async-components/structures/ErrorView.tsx +++ b/src/async-components/structures/ErrorView.tsx @@ -10,7 +10,7 @@ import { Text, Heading, Button, Separator } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; import SdkConfig from "../../SdkConfig"; -import { Flex } from "../../components/utils/Flex"; +import { Flex } from "../../shared-components/utils/Flex"; import { _t } from "../../languageHandler"; import { Icon as AppleIcon } from "../../../res/themes/element/img/compound/apple.svg"; import { Icon as MicrosoftIcon } from "../../../res/themes/element/img/compound/microsoft.svg"; @@ -58,7 +58,7 @@ const MobileAppLinks: React.FC<{ googlePlayUrl?: string; fdroidUrl?: string; }> = ({ appleAppStoreUrl, googlePlayUrl, fdroidUrl }) => ( - + {appleAppStoreUrl && ( Apple App Store @@ -84,7 +84,7 @@ const DesktopAppLinks: React.FC<{ linuxUrl?: string; }> = ({ macOsUrl, win64Url, win64ArmUrl, linuxUrl }) => { return ( - + {macOsUrl && ( diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e61713ca69..39c0147da0 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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 { 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.getJoinedMembers().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( - - {" " /* Whitespace, otherwise the sentences get smashed together */} - {warning} - , - ); - } + + const warning = + maxUserLevel >= 100 + ? _t("leave_room_dialog|room_leave_admin_warning") + : _t("leave_room_dialog|room_leave_mod_warning"); + warnings.push( + + {" " /* Whitespace, otherwise the sentences get smashed together */} + {warning} + , + ); } } diff --git a/src/components/utils/ListView.tsx b/src/components/utils/ListView.tsx new file mode 100644 index 0000000000..585552e23a --- /dev/null +++ b/src/components/utils/ListView.tsx @@ -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 = { + /** 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 + extends Omit>, "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, + 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(props: IListViewProps): 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(null); + /** Reference to the DOM element containing the virtualized list */ + const virtuosoDomRef = useRef(null); + /** Key of the item that should have tabIndex == 0 */ + const [tabIndexKey, setTabIndexKey] = useState( + props.items[0] ? getItemKey(props.items[0]) : undefined, + ); + /** Range of currently visible items in the viewport */ + const [visibleRange, setVisibleRange] = useState(undefined); + /** Map from item keys to their indices in the items array */ + const [keyToIndexMap, setKeyToIndexMap] = useState>(new Map()); + /** Whether the list is currently scrolling to an item */ + const isScrollingToItem = useRef(false); + /** Whether the list is currently focused */ + const [isFocused, setIsFocused] = useState(false); + + // Update the key-to-index mapping whenever items change + useEffect(() => { + const newKeyToIndexMap = new Map(); + 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): 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 = { + tabIndexKey: tabIndexKey, + focused: isFocused, + context: props.context || ({} as Context), + }; + + return ( + + ); +} diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index a03f703511..55220d29a9 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -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, }; diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 4f6814caae..d99c127d85 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -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,16 +27,17 @@ export interface MemberTileViewState extends MemberTileViewModelProps { e2eStatus?: E2EStatus; name: string; onClick: () => void; - title?: string; userLabel?: string; } export enum PowerStatus { + Creator = "creator", Admin = "admin", Moderator = "moderator", } const PowerLabel: Record = { + [PowerStatus.Creator]: _td("power_level|creator"), [PowerStatus.Admin]: _td("power_level|admin"), [PowerStatus.Moderator]: _td("power_level|moderator"), }; @@ -117,6 +117,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT const name = props.member.name; const powerStatusMap = new Map([ + [Infinity, PowerStatus.Creator], [100, PowerStatus.Admin], [50, PowerStatus.Moderator], ]); @@ -130,15 +131,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 +141,6 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT } return { - title, member, name, onClick, diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index 3a5a88f78d..a432baac72 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -290,7 +290,7 @@ export default class LoginWithQRFlow extends React.Component { data-testid="back-button" className="mx_LoginWithQR_BackButton" onClick={this.handleClick(Click.Back)} - title="Back" + title={_t("action|back")} > diff --git a/src/components/views/avatars/RoomAvatarView.tsx b/src/components/views/avatars/RoomAvatarView.tsx index 5ddf355d6f..408690fa1d 100644 --- a/src/components/views/avatars/RoomAvatarView.tsx +++ b/src/components/views/avatars/RoomAvatarView.tsx @@ -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 ; 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,22 +50,15 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { return (
- {icon} + {label ? {icon} : icon}
); } -type PresenceDecorationProps = { - /** - * The presence of the user in the DM room. - */ - presence: NonNullable; -}; - /** - * Component to display the presence of a user in a DM room. + * Get the decoration for the avatar based on the presence. */ -function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element { +function getPresenceDecoration(presence: Presence): JSX.Element { switch (presence) { case Presence.Online: return ( @@ -72,7 +67,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 +77,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 +87,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 +97,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 +111,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 +121,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) { @@ -135,11 +130,45 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen width="16px" height="16px" className="mx_RoomAvatarView_icon" - color="var(--cpd-color-icon-tertiary)" - aria-label={_t("room|header|room_is_public")} + color="var(--cpd-color-icon-info-primary)" + aria-label={getDecorationLabel(decoration, presence)} /> ); } else if (decoration === AvatarBadgeDecoration.Presence) { - return ; + return getPresenceDecoration(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"); } } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 62536acfd6..81a403bacf 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -358,20 +358,22 @@ export default class InviteDialog extends React.PureComponent excludedIds.add(m.userId)); room.getMembersWithMembership(KnownMembership.Join).forEach((m) => excludedIds.add(m.userId)); // add banned users, so we don't try to invite them room.getMembersWithMembership(KnownMembership.Ban).forEach((m) => excludedIds.add(m.userId)); - if (isFederated === false) { + const ourHomeserver = cli.getDomain(); + if (isFederated === false && ourHomeserver) { + // If this room isn't federated, we must be on the same server. // exclude users from external servers - const homeserver = props.roomId.split(":")[1]; - this.excludeExternals(homeserver, excludedIds); + this.excludeExternals(ourHomeserver, excludedIds); } } @@ -385,7 +387,7 @@ export default class InviteDialog extends React.PureComponent { - 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 = ({ space, onFinished }) => { const spaceChildren = useMemo(() => { const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId)); diff --git a/src/components/views/dialogs/devtools/RoomState.tsx b/src/components/views/dialogs/devtools/RoomState.tsx index 0a41743372..1f33fc5eea 100644 --- a/src/components/views/dialogs/devtools/RoomState.tsx +++ b/src/components/views/dialogs/devtools/RoomState.tsx @@ -19,6 +19,7 @@ import FilteredList from "./FilteredList"; import Spinner from "../../elements/Spinner"; import SyntaxHighlight from "../../elements/SyntaxHighlight"; import { useAsyncMemo } from "../../../../hooks/useAsyncMemo"; +import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch"; export const StateEventEditor: React.FC = ({ mxEvent, onBack }) => { const context = useContext(DevtoolsContext); @@ -114,6 +115,7 @@ const RoomStateExplorerEventType: React.FC = ({ eventType, onBa const [query, setQuery] = useState(""); const [event, setEvent] = useState(null); const [history, setHistory] = useState(false); + const [showEmptyState, setShowEmptyState] = useState(true); const events = context.room.currentState.events.get(eventType)!; @@ -149,10 +151,17 @@ const RoomStateExplorerEventType: React.FC = ({ eventType, onBa return ( - {Array.from(events.entries()).map(([stateKey, ev]) => ( - setEvent(ev)} /> - ))} + {Array.from(events.entries()) + .filter(([_, ev]) => showEmptyState || Object.keys(ev.getContent()).length > 0) + .map(([stateKey, ev]) => ( + setEvent(ev)} /> + ))} + ); }; diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 10f4788580..bfe99b80c8 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -14,7 +14,7 @@ import React, { type ChangeEvent, type FormEvent } from "react"; import { type SecretStorage } from "matrix-js-sdk/src/matrix"; import Field from "../../elements/Field"; -import { Flex } from "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { _t } from "../../../../languageHandler"; import { EncryptionCard } from "../../settings/encryption/EncryptionCard"; import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons"; diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 479e792fca..0072dd42a0 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -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 ? : undefined; @@ -45,7 +53,7 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I return ( = ({ value={filter} onFilterChange={onFilterChange} tabs={[ - { id: "ACTIVE", label: "Active polls" }, - { id: "ENDED", label: "Past polls" }, + { id: "ACTIVE", label: _t("right_panel|poll|active_heading") }, + { id: "ENDED", label: _t("right_panel|poll|past_heading") }, ]} /> {!!pollStartEvents.length && ( diff --git a/src/components/views/right_panel/EmptyState.tsx b/src/components/views/right_panel/EmptyState.tsx index f6b2a940a6..547f365463 100644 --- a/src/components/views/right_panel/EmptyState.tsx +++ b/src/components/views/right_panel/EmptyState.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ComponentType } from "react"; import { Text } from "@vector-im/compound-web"; -import { Flex } from "../../utils/Flex"; +import { Flex } from "../../../shared-components/utils/Flex"; interface Props { Icon: ComponentType>; diff --git a/src/components/views/right_panel/RoomSummaryCardView.tsx b/src/components/views/right_panel/RoomSummaryCardView.tsx index d994e697c6..f0cd8f2967 100644 --- a/src/components/views/right_panel/RoomSummaryCardView.tsx +++ b/src/components/views/right_panel/RoomSummaryCardView.tsx @@ -46,9 +46,9 @@ import RoomAvatar from "../avatars/RoomAvatar.tsx"; import { E2EStatus } from "../../../utils/ShieldUtils.ts"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks.ts"; import RoomName from "../elements/RoomName.tsx"; -import { Flex } from "../../utils/Flex.tsx"; +import { Flex } from "../../../shared-components/utils/Flex"; import { Linkify, topicToHtml } from "../../../HtmlUtils.tsx"; -import { Box } from "../../utils/Box.tsx"; +import { Box } from "../../../shared-components/utils/Box"; import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx"; import { useRoomSummaryCardViewModel } from "../../viewmodels/right_panel/RoomSummaryCardViewModel.tsx"; import { useRoomTopicViewModel } from "../../viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx"; @@ -80,7 +80,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null gap="var(--cpd-space-2x)" className="mx_RoomSummaryCard_topic" > - + {_t("right_panel|add_topic")} @@ -103,7 +103,7 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null mx_RoomSummaryCard_topic_collapsed: !vm.expanded, })} > - + {content} @@ -169,8 +169,8 @@ const RoomSummaryCardView: React.FC = ({ {!vm.isDirectMessage && vm.roomJoinRule === JoinRule.Public && ( - - + + {_t("common|public_room")} )} @@ -183,8 +183,8 @@ const RoomSummaryCardView: React.FC = ({ )} {!vm.isRoomEncrypted && ( - - + + {_t("common|unencrypted")} )} diff --git a/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx b/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx index a8a66c8c7d..9d42f618dd 100644 --- a/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx +++ b/src/components/views/right_panel/user_info/UserInfoHeaderVerificationView.tsx @@ -12,7 +12,7 @@ import { VerifiedIcon } from "@vector-im/compound-design-tokens/assets/web/icons import { useUserInfoVerificationViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel"; import { type IDevice } from "../UserInfo"; -import { Flex } from "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { _t } from "../../../../languageHandler"; export const UserInfoHeaderVerificationView: React.FC<{ diff --git a/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx b/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx index 25dd0a5cde..71c38f3baa 100644 --- a/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx +++ b/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx @@ -12,7 +12,7 @@ import { Heading, Tooltip, Text } from "@vector-im/compound-web"; import { useUserfoHeaderViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; import MemberAvatar from "../../avatars/MemberAvatar"; import { Container, type Member, type IDevice } from "../UserInfo"; -import { Flex } from "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import PresenceLabel from "../../rooms/PresenceLabel"; import CopyableText from "../../elements/CopyableText"; import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView"; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 27afc4debb..accf22873d 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -729,11 +729,6 @@ export class UnwrappedEventTile extends React.Component if (this.state.shieldColour !== EventShieldColour.NONE) { let shieldReasonMessage: string; switch (this.state.shieldReason) { - case null: - case EventShieldReason.UNKNOWN: - shieldReasonMessage = _t("error|unknown"); - break; - case EventShieldReason.UNVERIFIED_IDENTITY: shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity"); break; @@ -761,6 +756,14 @@ export class UnwrappedEventTile extends React.Component case EventShieldReason.VERIFICATION_VIOLATION: shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); break; + + case EventShieldReason.MISMATCHED_SENDER: + shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender"); + break; + + default: + shieldReasonMessage = _t("error|unknown"); + break; } if (this.state.shieldColour === EventShieldColour.GREY) { diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index 88fc07881c..5d096c7228 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details. import { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web"; import React from "react"; -import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid"; +import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { Flex } from "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { type MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel"; import { _t } from "../../../../languageHandler"; @@ -19,10 +19,10 @@ interface TooltipProps { children: React.ReactNode; } -const OptionalTooltip: React.FC = ({ canInvite, children }) => { - if (canInvite) return children; +const InviteTooltip: React.FC = ({ 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 {children}; + return {children}; }; interface Props { @@ -42,10 +42,10 @@ const InviteButton: React.FC = ({ vm }) => { if (shouldShowSearch) { /// When rendered alongside a search box, the invite button is just an icon. return ( - + - + ); }; diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx index 8901a77b95..fec82a68f2 100644 --- a/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/src/components/views/rooms/MemberList/MemberListView.tsx @@ -6,11 +6,9 @@ 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 "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { type MemberWithSeparator, SEPARATOR, @@ -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 = (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
; + return "separator"; } else if (item.member) { - return ; + return `member-${item.member.userId}`; } else { - return ; + 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, + 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
; + } else if (item.member) { + return ( + + ); + } else { + return ( + + ); + } + }, + [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
; - } - const item = vm.members[index]; - return ( -
- {getRowComponent(item)} -
- ); - }; + 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 ( = (props: IProps) => { header={_t("common|people")} onClose={props.onClose} > - - {({ onKeyDownHandler }) => ( - - e.preventDefault()}> - - - - {({ height, width }) => ( - - )} - - - )} - + + e.preventDefault()}> + + + + ); }; diff --git a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index f5fd5203a5..99a15893af 100644 --- a/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -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 = ; + const nameJSX = ; const presenceState = member.presenceState; let presenceJSX: JSX.Element | undefined; @@ -54,13 +59,18 @@ export function RoomMemberTileView(props: IProps): JSX.Element { return ( ); } diff --git a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx index 4f6caf06f6..e4f5d1dbaf 100644 --- a/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx +++ b/src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView.tsx @@ -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 =
- -
+ +
{props.avatarJsx} {props.presenceJsx}
{props.nameJsx}
-
+
{userLabelJsx} {props.iconJsx}
- +
); } diff --git a/src/components/views/rooms/NotificationDecoration.tsx b/src/components/views/rooms/NotificationDecoration.tsx index a93f1428fd..a6c6c5c93a 100644 --- a/src/components/views/rooms/NotificationDecoration.tsx +++ b/src/components/views/rooms/NotificationDecoration.tsx @@ -13,7 +13,7 @@ import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/vi import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid"; import { UnreadCounter, Unread } from "@vector-im/compound-web"; -import { Flex } from "../../utils/Flex"; +import { Flex } from "../../../shared-components/utils/Flex"; import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; diff --git a/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/src/components/views/rooms/RoomHeader/RoomHeader.tsx index 663c8e9ff0..281df80a29 100644 --- a/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -25,8 +25,8 @@ import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStore import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx"; import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts"; import { _t } from "../../../../languageHandler.tsx"; -import { Flex } from "../../../utils/Flex.tsx"; -import { Box } from "../../../utils/Box.tsx"; +import { Flex } from "../../../../shared-components/utils/Flex"; +import { Box } from "../../../../shared-components/utils/Box"; import { getPlatformCallTypeProps, useRoomCall } from "../../../../hooks/room/useRoomCall.tsx"; import { useRoomThreadNotifications } from "../../../../hooks/room/useRoomThreadNotifications.ts"; import { useGlobalNotificationState } from "../../../../hooks/useGlobalNotificationState.ts"; @@ -286,7 +286,8 @@ export default function RoomHeader({ diff --git a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx b/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx index 0e3412f588..66d881cfdb 100644 --- a/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx +++ b/src/components/views/rooms/RoomListPanel/EmptyRoomList.tsx @@ -7,11 +7,11 @@ import React, { type JSX, type PropsWithChildren } from "react"; import { Button } from "@vector-im/compound-web"; -import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add"; +import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat"; import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room"; import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel"; -import { Flex } from "../../../utils/Flex"; +import { Flex } from "../../../../shared-components/utils/Flex"; import { _t } from "../../../../languageHandler"; import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters"; import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms"; @@ -148,8 +148,8 @@ function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element { direction="column" gap="var(--cpd-space-4x)" > - {vm.canCreateRoom && ( {displayDialButton && (
should show the empty state when there are no pi tabindex="-1" >
has button to edit topic 1`] = ` title="" />
has button to edit topic 1`] = ` has button to edit topic 1`] = `

has button to edit topic 1`] = `

renders the room summary 1`] = ` title="" />
renders the room summary 1`] = ` renders the room summary 1`] = `
renders the room topic in the summary 1`] = ` title="" />
renders the room topic in the summary 1`] = ` renders the room topic in the summary 1`] = `

renders the room topic in the summary 1`] = `

with crypto enabled renders 1`] = ` class="mx_UserInfo_container mx_UserInfo_header" >

with crypto enabled renders 1`] = ` dir="auto" >
@user:example.com @@ -113,7 +113,7 @@ exports[` with crypto enabled renders 1`] = `

with crypto enabled should render a deactivate button for class="mx_UserInfo_container mx_UserInfo_header" >

with crypto enabled should render a deactivate button for dir="auto" >
@user:example.com @@ -400,7 +400,7 @@ exports[` with crypto enabled should render a deactivate button for

renders verification unavailable message 1`] = `

renders verification unavailable mes exports[` renders verified badge when user is verified 1`] = `

renders verified badge when user is exports[` renders verify button 1`] = `
renders custom user identifiers in the header 1` class="mx_UserInfo_container mx_UserInfo_header" >

renders custom user identifiers in the header 1` dir="auto" >
@user:example.com @@ -62,7 +62,7 @@ exports[` renders custom user identifiers in the header 1`

{ [EventShieldReason.MISMATCHED_SENDER_KEY, "Encrypted by an unverified session"], [EventShieldReason.SENT_IN_CLEAR, "Not encrypted"], [EventShieldReason.VERIFICATION_VIOLATION, "Sender's verified identity was reset"], + [ + EventShieldReason.MISMATCHED_SENDER, + "The sender of the event does not match the owner of the device that sent it.", + ], ])("shows the correct reason code for %i (%s)", async (reasonCode: EventShieldReason, expectedText: string) => { mxEvent = await mkEncryptedMatrixEvent({ plainContent: { msgtype: "m.text", body: "msg1" }, diff --git a/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap index 93b5229029..95d306e6d1 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomHeader/__snapshots__/RoomHeader-test.tsx.snap @@ -3,7 +3,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
", () => { expect(screen.getByText("No chats yet")).toBeInTheDocument(); expect(asFragment()).toMatchSnapshot(); - await user.click(screen.getByRole("button", { name: "New message" })); + await user.click(screen.getByRole("button", { name: "Start chat" })); expect(vm.createChatRoom).toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "New room" })); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx index 41ef9b0cf1..298ca9eae3 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomListPanel/RoomListHeaderView-test.tsx @@ -69,7 +69,7 @@ describe("", () => { expect(screen.queryByRole("button", { name: "Add" })).toBeNull(); expect(asFragment()).toMatchSnapshot(); - await user.click(screen.getByRole("button", { name: "New message" })); + await user.click(screen.getByRole("button", { name: "Start chat" })); expect(defaultValue.createChatRoom).toHaveBeenCalled(); }); @@ -80,7 +80,7 @@ describe("", () => { const openMenu = screen.getByRole("button", { name: "Add" }); await user.click(openMenu); - await user.click(screen.getByRole("menuitem", { name: "New message" })); + await user.click(screen.getByRole("menuitem", { name: "Start chat" })); expect(defaultValue.createChatRoom).toHaveBeenCalled(); await user.click(openMenu); diff --git a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap index 99314b38fe..9722db9cba 100644 --- a/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/RoomListPanel/__snapshots__/EmptyRoomList-test.tsx.snap @@ -3,7 +3,7 @@ exports[` should display empty state for filter favourite 1`] = `
@@ -24,7 +24,7 @@ exports[` should display empty state for filter favourite 1`] = exports[` should display empty state for filter people 1`] = `
@@ -45,7 +45,7 @@ exports[` should display empty state for filter people 1`] = ` exports[` should display empty state for filter rooms 1`] = `
@@ -66,7 +66,7 @@ exports[` should display empty state for filter rooms 1`] = ` exports[` should display the empty state for the invite filter 1`] = `
@@ -91,7 +91,7 @@ exports[` should display the empty state for the invite filter exports[` should display the empty state for the low priority filter 1`] = `
@@ -116,7 +116,7 @@ exports[` should display the empty state for the low priority f exports[` should display the empty state for the mention filter 1`] = `
@@ -141,7 +141,7 @@ exports[` should display the empty state for the mention filter exports[` should display the empty state for the unread filter 1`] = `
@@ -166,7 +166,7 @@ exports[` should display the empty state for the unread filter exports[` should not render the new room button if the user doesn't have the rights to create a room 1`] = `
@@ -181,7 +181,7 @@ exports[` should not render the new room button if the user doe Get started by messaging someone
@@ -213,7 +213,7 @@ exports[` should not render the new room button if the user doe exports[` should render the default placeholder when there is no filter 1`] = `
@@ -228,7 +228,7 @@ exports[` should render the default placeholder when there is n Get started by messaging someone or by creating a room

Deleting key storage will remove your cryptographic identity and message keys from the server and turn off the following security features: diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap index e745de0695..1a00f42660 100644 --- a/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap +++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/ResetIdentityPanel-test.tsx.snap @@ -84,7 +84,7 @@ exports[` should display the 'forgot recovery key' variant

    should display the 'sync failed' variant correct

    should reset the encryption when the continue bu
    should reset the encryption when the continue bu