Compare commits
55 Commits
v1.11.97
...
langleyd/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c67cec3f56 | ||
|
|
8b1d0f5aff | ||
|
|
57f5832a63 | ||
|
|
d52b0a1467 | ||
|
|
986be9c00d | ||
|
|
475e449e81 | ||
|
|
7ce0a76414 | ||
|
|
2e71ec748f | ||
|
|
07d5a72f26 | ||
|
|
1430fd5af6 | ||
|
|
779543fa0f | ||
|
|
6b052fd067 | ||
|
|
f39f3d2164 | ||
|
|
c6b1a09b55 | ||
|
|
c929eedd81 | ||
|
|
bcd396e19e | ||
|
|
ca56c2e091 | ||
|
|
d594441b53 | ||
|
|
d4f25e8e13 | ||
|
|
d70d4486f0 | ||
|
|
60117b92d8 | ||
|
|
afc8536d1c | ||
|
|
b5993aaabb | ||
|
|
e1b2e3a101 | ||
|
|
f54fbf7231 | ||
|
|
01bfaec729 | ||
|
|
ab51ff6b7e | ||
|
|
803cb36d60 | ||
|
|
24167871e6 | ||
|
|
2bc7223c1c | ||
|
|
8fc6638d6e | ||
|
|
e2b7852998 | ||
|
|
c24a1baf38 | ||
|
|
d337106eed | ||
|
|
5ce5e9092b | ||
|
|
cb657d6848 | ||
|
|
1f9db9fa1a | ||
|
|
ac3667508f | ||
|
|
149b3b1049 | ||
|
|
d07a02fe3d | ||
|
|
9d8d407019 | ||
|
|
617fcdd4ce | ||
|
|
df38e16dbb | ||
|
|
817d7b78b8 | ||
|
|
31a59a5fa3 | ||
|
|
55f1c27184 | ||
|
|
92b85fcb13 | ||
|
|
82d93695a2 | ||
|
|
637ba3222e | ||
|
|
abbc1c0947 | ||
|
|
602e65ff52 | ||
|
|
e915e40e39 | ||
|
|
35bf6afe55 | ||
|
|
52c8867e67 | ||
|
|
b217271027 |
2
.github/workflows/docker.yaml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
cosign sign --yes ${images}
|
||||
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@e98e4d1628a5f3be2be7c231e50981aee98723ae # v4
|
||||
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Skip SonarCloud in merge queue
|
||||
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
|
||||
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4
|
||||
uses: guibranco/github-status-action-v2@9b1d102b3c32583174557f58c53e3b09d43d1b1d
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "Element",
|
||||
"description": "A glossy Matrix collaboration client for the web.",
|
||||
"repository": {
|
||||
"url": "https://github.com/element-hq/element-web",
|
||||
"license": "AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial"
|
||||
},
|
||||
"bugs": {
|
||||
"list": "https://github.com/element-hq/element-web/issues",
|
||||
"report": "https://github.com/element-hq/element-web/issues/new/choose"
|
||||
},
|
||||
"keywords": ["chat", "riot", "matrix"]
|
||||
}
|
||||
@@ -46,7 +46,6 @@
|
||||
- [Skinning](skinning.md)
|
||||
- [Cider editor](ciderEditor.md)
|
||||
- [Iconography](icons.md)
|
||||
- [Jitsi](jitsi.md)
|
||||
- [Local echo](local-echo-dev.md)
|
||||
- [Media](media-handling.md)
|
||||
- [Room List Store](room-list-store.md)
|
||||
|
||||
36
package.json
@@ -22,8 +22,7 @@
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"AUTHORS.rst",
|
||||
"package.json",
|
||||
"contribute.json"
|
||||
"package.json"
|
||||
],
|
||||
"style": "bundle.css",
|
||||
"matrix_i18n_extra_translation_funcs": [
|
||||
@@ -69,13 +68,14 @@
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/pretty-format/react-is": "19.0.0",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"oidc-client-ts": "3.2.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001704",
|
||||
"testcontainers": "10.21.0",
|
||||
"caniuse-lite": "1.0.30001707",
|
||||
"testcontainers": "10.23.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -86,15 +86,15 @@
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
"@matrix-org/analytics-events": "^0.29.2",
|
||||
"@matrix-org/emojibase-bindings": "^1.3.4",
|
||||
"@matrix-org/emojibase-bindings": "^1.4.0",
|
||||
"@matrix-org/react-sdk-module-api": "^2.4.0",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^9.0.0",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.9.0",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.2",
|
||||
"@vector-im/compound-web": "^7.10.1",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.3",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
@@ -109,7 +109,7 @@
|
||||
"diff-dom": "^5.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"domutils": "^3.2.2",
|
||||
"emojibase-regex": "15.3.2",
|
||||
"emojibase-regex": "16.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"filesize": "10.1.6",
|
||||
@@ -130,7 +130,7 @@
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "37.3.0",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -141,19 +141,19 @@
|
||||
"posthog-js": "1.157.2",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "^18.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-blurhash": "^0.3.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-focus-lock": "^2.5.1",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-transition-group": "^4.4.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"rfc4648": "^1.4.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "2.14.0",
|
||||
"sanitize-html": "2.15.0",
|
||||
"tar-js": "^0.3.0",
|
||||
"temporal-polyfill": "^0.2.5",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ua-parser-js": "^1.0.2",
|
||||
"uuid": "^11.0.0",
|
||||
"what-input": "^5.2.10"
|
||||
@@ -211,11 +211,11 @@
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/tar-js": "^0.3.5",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
|
||||
index 6ea73ef..cb51757 100644
|
||||
--- a/node_modules/@types/react/index.d.ts
|
||||
+++ b/node_modules/@types/react/index.d.ts
|
||||
@@ -151,7 +151,7 @@ declare namespace React {
|
||||
/**
|
||||
* The current value of the ref.
|
||||
*/
|
||||
- readonly current: T | null;
|
||||
+ current: T;
|
||||
}
|
||||
|
||||
interface DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES {
|
||||
@@ -186,7 +186,7 @@ declare namespace React {
|
||||
* @see {@link RefObject}
|
||||
*/
|
||||
|
||||
- type Ref<T> = RefCallback<T> | RefObject<T> | null;
|
||||
+ type Ref<T> = RefCallback<T> | RefObject<T | null> | null;
|
||||
/**
|
||||
* A legacy implementation of refs where you can pass a string to a ref prop.
|
||||
*
|
||||
@@ -300,7 +300,7 @@ declare namespace React {
|
||||
*
|
||||
* @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
|
||||
*/
|
||||
- ref?: LegacyRef<T> | undefined;
|
||||
+ ref?: LegacyRef<T | null> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1234,7 +1234,7 @@ declare namespace React {
|
||||
*
|
||||
* @see {@link ForwardRefRenderFunction}
|
||||
*/
|
||||
- type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;
|
||||
+ type ForwardedRef<T> = ((instance: T | null) => void) | RefObject<T | null> | null;
|
||||
|
||||
/**
|
||||
* The type of the function passed to {@link forwardRef}. This is considered different
|
||||
@@ -1565,7 +1565,7 @@ declare namespace React {
|
||||
[propertyName: string]: any;
|
||||
}
|
||||
|
||||
- function createRef<T>(): RefObject<T>;
|
||||
+ function createRef<T>(): RefObject<T | null>;
|
||||
|
||||
/**
|
||||
* The type of the component returned from {@link forwardRef}.
|
||||
@@ -1989,7 +1989,7 @@ declare namespace React {
|
||||
* @version 16.8.0
|
||||
* @see {@link https://react.dev/reference/react/useRef}
|
||||
*/
|
||||
- function useRef<T>(initialValue: T): MutableRefObject<T>;
|
||||
+ function useRef<T>(initialValue: T): RefObject<T>;
|
||||
// convenience overload for refs given as a ref prop as they typically start with a null value
|
||||
/**
|
||||
* `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
|
||||
@@ -2004,7 +2004,7 @@ declare namespace React {
|
||||
* @version 16.8.0
|
||||
* @see {@link https://react.dev/reference/react/useRef}
|
||||
*/
|
||||
- function useRef<T>(initialValue: T | null): RefObject<T>;
|
||||
+ function useRef<T>(initialValue: T | null): RefObject<T | null>;
|
||||
// convenience overload for potentially undefined initialValue / call with 0 arguments
|
||||
// has a default to stop it from defaulting to {} instead
|
||||
/**
|
||||
@@ -2017,7 +2017,7 @@ declare namespace React {
|
||||
* @version 16.8.0
|
||||
* @see {@link https://react.dev/reference/react/useRef}
|
||||
*/
|
||||
- function useRef<T = undefined>(initialValue?: undefined): MutableRefObject<T | undefined>;
|
||||
+ function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;
|
||||
/**
|
||||
* The signature is identical to `useEffect`, but it fires synchronously after all DOM mutations.
|
||||
* Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside
|
||||
31
patches/@types+react+19.0.10.patch
Normal file
@@ -0,0 +1,31 @@
|
||||
diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
|
||||
index 2272032..18bd20a 100644
|
||||
--- a/node_modules/@types/react/index.d.ts
|
||||
+++ b/node_modules/@types/react/index.d.ts
|
||||
@@ -134,7 +134,7 @@ declare namespace React {
|
||||
props: P,
|
||||
) => ReactNode | Promise<ReactNode>)
|
||||
// constructor signature must match React.Component
|
||||
- | (new(props: P) => Component<any, any>);
|
||||
+ | (new(props: P, context?: any) => Component<any, any>);
|
||||
|
||||
/**
|
||||
* Created by {@link createRef}, or {@link useRef} when passed `null`.
|
||||
@@ -941,7 +941,7 @@ declare namespace React {
|
||||
context: unknown;
|
||||
|
||||
// Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
|
||||
- constructor(props: P);
|
||||
+ constructor(props: P, context?: unknown);
|
||||
|
||||
// We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
|
||||
// See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
|
||||
@@ -1113,7 +1113,7 @@ declare namespace React {
|
||||
*/
|
||||
interface ComponentClass<P = {}, S = ComponentState> extends StaticLifecycle<P, S> {
|
||||
// constructor signature must match React.Component
|
||||
- new(props: P): Component<P, S>;
|
||||
+ new(props: P, context?: any): Component<P, S>;
|
||||
/**
|
||||
* Ignored by React.
|
||||
* @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.
|
||||
22
patches/react-blurhash+0.3.0.patch
Normal file
@@ -0,0 +1,22 @@
|
||||
diff --git a/node_modules/react-blurhash/dist/index.d.ts b/node_modules/react-blurhash/dist/index.d.ts
|
||||
index 3adbd0a..32e8c13 100644
|
||||
--- a/node_modules/react-blurhash/dist/index.d.ts
|
||||
+++ b/node_modules/react-blurhash/dist/index.d.ts
|
||||
@@ -19,7 +19,7 @@ declare class Blurhash extends React.PureComponent<Props$1> {
|
||||
resolutionY: number;
|
||||
};
|
||||
componentDidUpdate(): void;
|
||||
- render(): JSX.Element;
|
||||
+ render(): React.JSX.Element;
|
||||
}
|
||||
|
||||
declare type Props = React.CanvasHTMLAttributes<HTMLCanvasElement> & {
|
||||
@@ -37,7 +37,7 @@ declare class BlurhashCanvas extends React.PureComponent<Props> {
|
||||
componentDidUpdate(): void;
|
||||
handleRef: (canvas: HTMLCanvasElement) => void;
|
||||
draw: () => void;
|
||||
- render(): JSX.Element;
|
||||
+ render(): React.JSX.Element;
|
||||
}
|
||||
|
||||
export { Blurhash, BlurhashCanvas };
|
||||
@@ -1,108 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { completeCreateSecretStorageDialog } from "./utils.ts";
|
||||
|
||||
async function expectBackupVersionToBe(page: Page, version: string) {
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(5) td")).toHaveText(
|
||||
version + " (Algorithm: m.megolm_backup.v1.curve25519-aes-sha2)",
|
||||
);
|
||||
|
||||
await expect(page.locator(".mx_SecureBackupPanel_statusList tr:nth-child(6) td")).toHaveText(version);
|
||||
}
|
||||
|
||||
test.describe("Backups", () => {
|
||||
test.skip(isDendrite, "Dendrite lacks support for MSC3967 so requires additional auth here");
|
||||
test.use({
|
||||
displayName: "Hanako",
|
||||
});
|
||||
|
||||
test(
|
||||
"Create, delete and recreate a keys backup",
|
||||
{ tag: "@no-webkit" },
|
||||
async ({ page, user, app }, workerInfo) => {
|
||||
// Create a backup
|
||||
const securityTab = await app.settings.openUserSettings("Security & Privacy");
|
||||
|
||||
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
|
||||
const securityKey = await completeCreateSecretStorageDialog(page);
|
||||
|
||||
// Open the settings again
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
||||
|
||||
// expand the advanced section to see the active version in the reports
|
||||
await page
|
||||
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
|
||||
.locator("..")
|
||||
.click();
|
||||
|
||||
await expectBackupVersionToBe(page, "1");
|
||||
|
||||
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
|
||||
const currentDialogLocator = page.locator(".mx_Dialog");
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
|
||||
// Delete it
|
||||
await currentDialogLocator.getByTestId("dialog-primary-button").click(); // Click "Delete Backup"
|
||||
|
||||
// Create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
await currentDialogLocator.getByLabel("Recovery Key").fill(securityKey);
|
||||
await currentDialogLocator.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
|
||||
// Should be successful
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Success!" })).toBeVisible();
|
||||
await currentDialogLocator.getByRole("button", { name: "OK", exact: true }).click();
|
||||
|
||||
// Open the settings again
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(securityTab.getByRole("heading", { name: "Secure Backup" })).toBeVisible();
|
||||
|
||||
// expand the advanced section to see the active version in the reports
|
||||
await page
|
||||
.locator(".mx_Dialog .mx_SettingsSubsection_content details .mx_SecureBackupPanel_advanced")
|
||||
.locator("..")
|
||||
.click();
|
||||
|
||||
await expectBackupVersionToBe(page, "2");
|
||||
|
||||
// ==
|
||||
// Ensure that if you don't have the secret storage passphrase the backup won't be created
|
||||
// ==
|
||||
|
||||
// First delete version 2
|
||||
await securityTab.getByRole("button", { name: "Delete Backup", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Delete Backup" })).toBeVisible();
|
||||
// Click "Delete Backup"
|
||||
await currentDialogLocator.getByTestId("dialog-primary-button").click();
|
||||
|
||||
// Try to create another
|
||||
await securityTab.getByRole("button", { name: "Set up", exact: true }).click();
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Recovery Key" })).toBeVisible();
|
||||
// But cancel the recovery key dialog, to simulate not having the secret storage passphrase
|
||||
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
|
||||
|
||||
await expect(currentDialogLocator.getByRole("heading", { name: "Starting backup…" })).toBeVisible();
|
||||
// check that it failed
|
||||
await expect(currentDialogLocator.getByText("Unable to create key backup")).toBeVisible();
|
||||
// cancel
|
||||
await currentDialogLocator.getByTestId("dialog-cancel-button").click();
|
||||
|
||||
// go back to the settings to check that no backup was created (the setup button should still be there)
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await expect(securityTab.getByRole("button", { name: "Set up", exact: true })).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -8,14 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../../element-web-test";
|
||||
import {
|
||||
autoJoin,
|
||||
completeCreateSecretStorageDialog,
|
||||
copyAndContinue,
|
||||
createSharedRoomWithUser,
|
||||
enableKeyBackup,
|
||||
verify,
|
||||
} from "./utils";
|
||||
import { autoJoin, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils";
|
||||
import { type Bot } from "../../pages/bot";
|
||||
import { type ElementAppPage } from "../../pages/ElementAppPage";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
@@ -84,86 +77,43 @@ test.describe("Cryptography", function () {
|
||||
},
|
||||
});
|
||||
|
||||
for (const isDeviceVerified of [true, false]) {
|
||||
test.describe(`setting up secure key backup should work isDeviceVerified=${isDeviceVerified}`, () => {
|
||||
/**
|
||||
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
|
||||
* @param keyType
|
||||
*/
|
||||
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
|
||||
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
|
||||
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
|
||||
keyType,
|
||||
);
|
||||
expect(accountData.encrypted).toBeDefined();
|
||||
const keys = Object.keys(accountData.encrypted);
|
||||
const key = accountData.encrypted[keys[0]];
|
||||
expect(key.ciphertext).toBeDefined();
|
||||
expect(key.iv).toBeDefined();
|
||||
expect(key.mac).toBeDefined();
|
||||
}
|
||||
/**
|
||||
* Verify that the `m.cross_signing.${keyType}` key is available on the account data on the server
|
||||
* @param keyType
|
||||
*/
|
||||
async function verifyKey(app: ElementAppPage, keyType: "master" | "self_signing" | "user_signing") {
|
||||
const accountData: { encrypted: Record<string, Record<string, string>> } = await app.client.evaluate(
|
||||
(cli, keyType) => cli.getAccountDataFromServer(`m.cross_signing.${keyType}`),
|
||||
keyType,
|
||||
);
|
||||
|
||||
test("by recovery code", async ({ page, app, user: aliceCredentials }) => {
|
||||
// Verified the device
|
||||
if (isDeviceVerified) {
|
||||
await app.client.bootstrapCrossSigning(aliceCredentials);
|
||||
}
|
||||
|
||||
await page.route("**/_matrix/client/v3/keys/signatures/upload", async (route) => {
|
||||
// We delay this API otherwise the `Setting up keys` may happen too quickly and cause flakiness
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
|
||||
|
||||
await completeCreateSecretStorageDialog(page);
|
||||
|
||||
// Verify that the SSSS keys are in the account data stored in the server
|
||||
await verifyKey(app, "master");
|
||||
await verifyKey(app, "self_signing");
|
||||
await verifyKey(app, "user_signing");
|
||||
});
|
||||
|
||||
test("by passphrase", async ({ page, app, user: aliceCredentials }) => {
|
||||
// Verified the device
|
||||
if (isDeviceVerified) {
|
||||
await app.client.bootstrapCrossSigning(aliceCredentials);
|
||||
}
|
||||
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await page.getByRole("button", { name: "Set up Secure Backup" }).click();
|
||||
|
||||
const dialog = page.locator(".mx_Dialog");
|
||||
// Select passphrase option
|
||||
await dialog.getByText("Enter a Security Phrase").click();
|
||||
await dialog.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Fill passphrase input
|
||||
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
|
||||
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
// Confirm passphrase
|
||||
await dialog.locator("input").fill("new passphrase for setting up a secure key backup");
|
||||
await dialog.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
|
||||
|
||||
await copyAndContinue(page);
|
||||
|
||||
await expect(dialog.getByText("Secure Backup successful")).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Done" }).click();
|
||||
await expect(dialog.getByText("Secure Backup successful")).not.toBeVisible();
|
||||
|
||||
// Verify that the SSSS keys are in the account data stored in the server
|
||||
await verifyKey(app, "master");
|
||||
await verifyKey(app, "self_signing");
|
||||
await verifyKey(app, "user_signing");
|
||||
});
|
||||
});
|
||||
expect(accountData.encrypted).toBeDefined();
|
||||
const keys = Object.keys(accountData.encrypted);
|
||||
const key = accountData.encrypted[keys[0]];
|
||||
expect(key.ciphertext).toBeDefined();
|
||||
expect(key.iv).toBeDefined();
|
||||
expect(key.mac).toBeDefined();
|
||||
}
|
||||
|
||||
test("Setting up key backup by recovery key", async ({ page, app, user: aliceCredentials }) => {
|
||||
await app.client.bootstrapCrossSigning(aliceCredentials);
|
||||
|
||||
await enableKeyBackup(app);
|
||||
|
||||
// Wait for the cross signing keys to be uploaded
|
||||
// Waiting for "Change the recovery key" button ensure that all the secrets are uploaded and cached locally
|
||||
const encryptionTab = await app.settings.openUserSettings("Encryption");
|
||||
await expect(encryptionTab.getByRole("button", { name: "Change recovery key" })).toBeVisible();
|
||||
|
||||
// Verify that the SSSS keys are in the account data stored in the server
|
||||
await verifyKey(app, "master");
|
||||
await verifyKey(app, "self_signing");
|
||||
await verifyKey(app, "user_signing");
|
||||
});
|
||||
|
||||
test("Can reset cross-signing keys", async ({ page, app, user: aliceCredentials }) => {
|
||||
await app.client.bootstrapCrossSigning(aliceCredentials);
|
||||
const secretStorageKey = await enableKeyBackup(app);
|
||||
await enableKeyBackup(app);
|
||||
|
||||
// Fetch the current cross-signing keys
|
||||
async function fetchMasterKey() {
|
||||
@@ -177,18 +127,15 @@ test.describe("Cryptography", function () {
|
||||
return k;
|
||||
});
|
||||
}
|
||||
|
||||
const masterKey1 = await fetchMasterKey();
|
||||
|
||||
// Find the "reset cross signing" button, and click it
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
await page.locator("div.mx_CrossSigningPanel_buttonRow").getByRole("button", { name: "Reset" }).click();
|
||||
// Find "the Reset cryptographic identity" button
|
||||
const encryptionTab = await app.settings.openUserSettings("Encryption");
|
||||
await encryptionTab.getByRole("button", { name: "Reset cryptographic identity" }).click();
|
||||
|
||||
// Confirm
|
||||
await page.getByRole("button", { name: "Clear cross-signing keys" }).click();
|
||||
|
||||
// Enter the 4S key
|
||||
await page.getByPlaceholder("Recovery Key").fill(secretStorageKey);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await encryptionTab.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Enter the password
|
||||
await page.getByPlaceholder("Password").fill(aliceCredentials.password);
|
||||
@@ -198,9 +145,6 @@ test.describe("Cryptography", function () {
|
||||
const masterKey2 = await fetchMasterKey();
|
||||
expect(masterKey1).not.toEqual(masterKey2);
|
||||
}).toPass();
|
||||
|
||||
// The dialog should have gone away
|
||||
await expect(page.locator(".mx_Dialog")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test(
|
||||
|
||||
@@ -22,20 +22,67 @@ test.describe("Room list filters and sort", () => {
|
||||
return page.getByRole("listbox", { name: "Room list filters" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the room list
|
||||
* @param page
|
||||
*/
|
||||
function getRoomList(page: Page) {
|
||||
return page.getByTestId("room-list");
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, bot, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
});
|
||||
|
||||
test.describe("Room list", () => {
|
||||
/**
|
||||
* Get the room list
|
||||
* @param page
|
||||
*/
|
||||
function getRoomList(page: Page) {
|
||||
return page.getByTestId("room-list");
|
||||
}
|
||||
test.describe("Scroll behaviour", () => {
|
||||
test("should scroll to the top of list when filter is applied and active room is not in filtered list", async ({
|
||||
page,
|
||||
app,
|
||||
}) => {
|
||||
const createFavouriteRoom = async (name: string) => {
|
||||
const id = await app.client.createRoom({
|
||||
name,
|
||||
});
|
||||
await app.client.evaluate(async (client, favouriteId) => {
|
||||
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
|
||||
}, id);
|
||||
};
|
||||
|
||||
// Create 5 favourite rooms
|
||||
let i = 0;
|
||||
for (; i < 5; i++) {
|
||||
await createFavouriteRoom(`room${i}-fav`);
|
||||
}
|
||||
|
||||
// Create a non-favourite room
|
||||
await app.client.createRoom({ name: `room-non-fav` });
|
||||
|
||||
// Create rest of the favourite rooms
|
||||
for (; i < 20; i++) {
|
||||
await createFavouriteRoom(`room${i}-fav`);
|
||||
}
|
||||
|
||||
// Open the non-favourite room
|
||||
const roomListView = getRoomList(page);
|
||||
const tile = roomListView.getByRole("gridcell", { name: "Open room room-non-fav" });
|
||||
await tile.scrollIntoViewIfNeeded();
|
||||
await tile.click();
|
||||
|
||||
// Enable Favourite filter
|
||||
const primaryFilters = getPrimaryFilters(page);
|
||||
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
|
||||
await expect(tile).not.toBeVisible();
|
||||
|
||||
// Ensure the room list is not scrolled
|
||||
const isScrolledDown = await page
|
||||
.getByRole("grid", { name: "Room list" })
|
||||
.evaluate((e) => e.scrollTop !== 0);
|
||||
expect(isScrolledDown).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Room list", () => {
|
||||
let unReadDmId: string | undefined;
|
||||
let unReadRoomId: string | undefined;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../../element-web-test";
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
|
||||
test.describe("Room list", () => {
|
||||
test.use({
|
||||
@@ -85,6 +85,48 @@ test.describe("Room list", () => {
|
||||
await expect(roomItem).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should open the notification options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
|
||||
await roomItem.hover();
|
||||
|
||||
await expect(roomItem).toMatchScreenshot("room-list-item-hover.png");
|
||||
let roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
|
||||
await roomItemMenu.click();
|
||||
|
||||
// Default settings should be selected
|
||||
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page).toMatchScreenshot("room-list-item-open-notification-options.png");
|
||||
|
||||
// It should make the room muted
|
||||
await page.getByRole("menuitem", { name: "Mute room" }).click();
|
||||
|
||||
// Remove hover on the room list item
|
||||
await roomListView.hover();
|
||||
|
||||
// Scroll to the bottom of the list
|
||||
await page.getByRole("grid", { name: "Room list" }).evaluate((e) => {
|
||||
e.scrollTop = e.scrollHeight;
|
||||
});
|
||||
|
||||
// The room decoration should have the muted icon
|
||||
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
|
||||
|
||||
await roomItem.hover();
|
||||
// On hover, the room should show the muted icon
|
||||
await expect(roomItem).toMatchScreenshot("room-list-item-hover-silent.png");
|
||||
|
||||
roomItemMenu = roomItem.getByRole("button", { name: "Notification options" });
|
||||
await roomItemMenu.click();
|
||||
// The Mute room option should be selected
|
||||
await expect(page.getByRole("menuitem", { name: "Mute room" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page).toMatchScreenshot("room-list-item-open-notification-options-selection.png");
|
||||
});
|
||||
|
||||
test("should scroll to the current room", async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
await roomListView.hover();
|
||||
@@ -102,6 +144,32 @@ test.describe("Room list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Avatar decoration", () => {
|
||||
test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] });
|
||||
|
||||
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
// @ts-ignore Visibility enum is not accessible
|
||||
await app.client.createRoom({ name: "public room", visibility: "public" });
|
||||
const roomListView = getRoomList(page);
|
||||
const publicRoom = roomListView.getByRole("gridcell", { name: "public room" });
|
||||
|
||||
await expect(publicRoom).toBeVisible();
|
||||
await expect(publicRoom).toMatchScreenshot("room-list-item-public.png");
|
||||
});
|
||||
|
||||
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
|
||||
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
|
||||
await page.getByRole("menuitem", { name: "New video room" }).click();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("video room");
|
||||
await page.getByRole("button", { name: "Create video room" }).click();
|
||||
|
||||
const roomListView = getRoomList(page);
|
||||
const videoRoom = roomListView.getByRole("gridcell", { name: "video room" });
|
||||
await expect(videoRoom).toBeVisible();
|
||||
await expect(videoRoom).toMatchScreenshot("room-list-item-video.png");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Notification decoration", () => {
|
||||
test("should render the invitation decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
@@ -43,6 +43,7 @@ test.describe("Pills", () => {
|
||||
|
||||
// go back to the message room and try to click on the pill text, as a user would
|
||||
await app.viewRoomByName(messageRoom);
|
||||
await expect(page).toHaveURL(new RegExp(`/#/room/${messageRoomId}`));
|
||||
const pillText = page.locator(".mx_EventTile_body .mx_Pill .mx_Pill_text");
|
||||
await expect(pillText).toHaveCSS("pointer-events", "none");
|
||||
await pillText.click({ force: true }); // force is to ensure we bypass pointer-events
|
||||
|
||||
@@ -136,13 +136,30 @@ test.describe("RightPanel", () => {
|
||||
});
|
||||
test.describe("room reporting", () => {
|
||||
test.skip(isDendrite, "Dendrite does not implement room reporting");
|
||||
test("should handle reporting a room", async ({ page, app }) => {
|
||||
test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||
await viewRoomSummaryByName(page, app, ROOM_NAME);
|
||||
|
||||
await page.getByRole("menuitem", { name: "Report room" }).click();
|
||||
const dialog = await page.getByRole("dialog", { name: "Report Room" });
|
||||
await dialog.getByLabel("reason").fill("This room should be reported");
|
||||
await expect(dialog).toMatchScreenshot("room-report-dialog.png");
|
||||
await dialog.getByRole("button", { name: "Send report" }).click();
|
||||
await expect(page.getByText("Your report was sent.")).toBeVisible();
|
||||
|
||||
// Dialog should have gone
|
||||
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
|
||||
});
|
||||
test("should handle reporting a room and leaving the room", async ({ page, app }) => {
|
||||
await viewRoomSummaryByName(page, app, ROOM_NAME);
|
||||
|
||||
await page.getByRole("menuitem", { name: "Report room" }).click();
|
||||
const dialog = await page.getByRole("dialog", { name: "Report room" });
|
||||
await dialog.getByRole("switch", { name: "Leave room" }).click();
|
||||
await dialog.getByLabel("reason").fill("This room should be reported");
|
||||
await dialog.getByRole("button", { name: "Send report" }).click();
|
||||
await page.getByRole("dialog", { name: "Leave room" }).getByRole("button", { name: "Leave" }).click();
|
||||
|
||||
// Dialog should have gone
|
||||
await expect(page.locator(".mx_Dialog")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
67
playwright/e2e/room/invites.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
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 { test, expect } from "../../element-web-test";
|
||||
|
||||
test.describe("Invites", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
botCreateOpts: {
|
||||
displayName: "Bob",
|
||||
},
|
||||
});
|
||||
|
||||
test("should render an invite view", { tag: "@screenshot" }, async ({ page, homeserver, user, bot, app }) => {
|
||||
const roomId = await bot.createRoom({ is_direct: true });
|
||||
await bot.inviteUser(roomId, user.userId);
|
||||
await app.viewRoomByName("Bob");
|
||||
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png");
|
||||
});
|
||||
|
||||
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
|
||||
const roomId = await bot.createRoom({ is_direct: true });
|
||||
await bot.inviteUser(roomId, user.userId);
|
||||
await app.viewRoomByName("Bob");
|
||||
await page.getByRole("button", { name: "Decline", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Welcome Alice", exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Bob", exact: true }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test(
|
||||
"should be able to decline an invite, report the room and ignore the user",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, homeserver, user, bot, app }) => {
|
||||
const roomId = await bot.createRoom({ is_direct: true });
|
||||
await bot.inviteUser(roomId, user.userId);
|
||||
await app.viewRoomByName("Bob");
|
||||
await page.getByRole("button", { name: "Decline and block" }).click();
|
||||
await page.getByLabel("Ignore user").click();
|
||||
await page.getByLabel("Report room").click();
|
||||
await page.getByLabel("Reason").fill("Do not want the room");
|
||||
const roomReported = page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().endsWith(`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/report`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await expect(page.getByRole("dialog", { name: "Decline invitation" })).toMatchScreenshot(
|
||||
"Invites_reject_dialog.png",
|
||||
);
|
||||
await page.getByRole("button", { name: "Decline invite" }).click();
|
||||
|
||||
// Check room was reported.
|
||||
await roomReported;
|
||||
|
||||
// Check user is ignored.
|
||||
await app.settings.openUserSettings("Security & Privacy");
|
||||
const ignoredUsersList = page.getByRole("list", { name: "Ignored users" });
|
||||
await ignoredUsersList.scrollIntoViewIfNeeded();
|
||||
await expect(ignoredUsersList.getByRole("listitem", { name: bot.credentials.userId })).toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -255,8 +255,8 @@ test.describe("Sliding Sync", () => {
|
||||
// Select the room to reject
|
||||
await page.getByRole("treeitem", { name: "Room to Reject" }).click();
|
||||
|
||||
// Reject the invite
|
||||
await page.locator(".mx_RoomView").getByRole("button", { name: "Reject", exact: true }).click();
|
||||
// Decline the invite
|
||||
await page.locator(".mx_RoomView").getByRole("button", { name: "Decline", exact: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -116,6 +116,7 @@
|
||||
@import "./views/auth/_Welcome.pcss";
|
||||
@import "./views/avatars/_BaseAvatar.pcss";
|
||||
@import "./views/avatars/_DecoratedRoomAvatar.pcss";
|
||||
@import "./views/avatars/_RoomAvatarView.pcss";
|
||||
@import "./views/avatars/_WidgetAvatar.pcss";
|
||||
@import "./views/avatars/_WithPresenceIndicator.pcss";
|
||||
@import "./views/beta/_BetaCard.pcss";
|
||||
@@ -340,8 +341,6 @@
|
||||
@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
|
||||
@import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss";
|
||||
@import "./views/settings/_AvatarSetting.pcss";
|
||||
@import "./views/settings/_CrossSigningPanel.pcss";
|
||||
@import "./views/settings/_CryptographyPanel.pcss";
|
||||
@import "./views/settings/_FontScalingPanel.pcss";
|
||||
@import "./views/settings/_ImageSizePanel.pcss";
|
||||
@import "./views/settings/_IntegrationManager.pcss";
|
||||
@@ -354,7 +353,6 @@
|
||||
@import "./views/settings/_PhoneNumbers.pcss";
|
||||
@import "./views/settings/_PowerLevelSelector.pcss";
|
||||
@import "./views/settings/_RoomProfileSettings.pcss";
|
||||
@import "./views/settings/_SecureBackupPanel.pcss";
|
||||
@import "./views/settings/_SetIntegrationManager.pcss";
|
||||
@import "./views/settings/_SettingsFieldset.pcss";
|
||||
@import "./views/settings/_SettingsHeader.pcss";
|
||||
|
||||
48
res/css/views/avatars/_RoomAvatarView.pcss
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_RoomAvatarView {
|
||||
--room-avatar-size: 32px;
|
||||
|
||||
position: relative;
|
||||
|
||||
/* Keep the container to the same size than the avatar */
|
||||
inline-size: var(--room-avatar-size);
|
||||
block-size: var(--room-avatar-size);
|
||||
|
||||
.mx_RoomAvatarView_RoomAvatar {
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.mx_RoomAvatarView_RoomAvatar_icon {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-icon-mask.svg");
|
||||
}
|
||||
|
||||
.mx_RoomAvatarView_RoomAvatar_presence {
|
||||
mask-image: url("$(res)/img/element-icons/roomlist/room-avatar-view-presence-mask.svg");
|
||||
}
|
||||
|
||||
.mx_RoomAvatarView_icon {
|
||||
position: absolute;
|
||||
|
||||
/* Place half the icon inside the avatar */
|
||||
/* Avatar size - (icon size (16px) / 2) */
|
||||
left: calc((var(--room-avatar-size) - 8px));
|
||||
bottom: var(--cpd-space-0-5x);
|
||||
}
|
||||
|
||||
.mx_RoomAvatarView_PresenceDecoration {
|
||||
position: absolute;
|
||||
|
||||
/* Place half the icon inside the avatar */
|
||||
/* Avatar size - (icon size (8px) / 2) */
|
||||
left: calc((var(--room-avatar-size) - 4px));
|
||||
bottom: var(--cpd-space-0-5x);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ 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.
|
||||
*/
|
||||
|
||||
.mx_ReportRoomDialog {
|
||||
.mx_ReportRoomDialog,
|
||||
.mx_DeclineAndBlockInviteDialog {
|
||||
textarea {
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
border: 1px solid var(--cpd-color-border-interactive-primary);
|
||||
@@ -13,4 +14,28 @@ Please see LICENSE files in the repository root for full details.
|
||||
border-radius: 0.5rem;
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
/*
|
||||
Workaround to fix labels appearing with the wrong color.
|
||||
|
||||
.mx_Dialog (in res/css/_common.pcss) redefines the body color
|
||||
as $light-fg-color rather than the standard primary color.
|
||||
|
||||
This forces the colour to match the Compound style, but
|
||||
in the future the Dialogs should not force a color.
|
||||
*/
|
||||
form label {
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_DeclineAndBlockInviteDialog {
|
||||
div[aria-disabled="true"] > label {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.mx_SettingsFlag_label {
|
||||
color: var(--cpd-color-text-primary);
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--cpd-color-icon-secondary);
|
||||
}
|
||||
|
||||
.mx_SpaceMenu_button {
|
||||
svg {
|
||||
transition: transform 0.1s linear;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
.mx_RoomListItemView_roomName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -71,3 +71,7 @@
|
||||
padding-right: var(--cpd-space-3x);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_bold .mx_RoomListItemView_roomName {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_CrossSigningPanel_statusList {
|
||||
border-spacing: 0;
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0;
|
||||
|
||||
&:first-of-type {
|
||||
padding-inline-end: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CrossSigningPanel_buttonRow {
|
||||
margin: 1em 0;
|
||||
|
||||
:nth-child(n + 1) {
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CrossSigningPanel_advanced {
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_CryptographyPanel_sessionInfo {
|
||||
padding: 0em;
|
||||
border-spacing: 0px;
|
||||
}
|
||||
.mx_CryptographyPanel_sessionInfo > tr {
|
||||
vertical-align: baseline;
|
||||
padding: 0em;
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0 1em 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_CryptographyPanel_importExportButtons {
|
||||
display: inline-flex;
|
||||
flex-flow: wrap;
|
||||
row-gap: $spacing-8;
|
||||
column-gap: $spacing-8;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.mx_SecureBackupPanel_deviceName {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mx_SecureBackupPanel_buttonRow {
|
||||
margin: 1em 0;
|
||||
display: inline-flex;
|
||||
flex-flow: wrap;
|
||||
row-gap: 10px;
|
||||
|
||||
:nth-child(n + 1) {
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SecureBackupPanel_statusList {
|
||||
border-spacing: 0;
|
||||
|
||||
th {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0;
|
||||
|
||||
&:first-of-type {
|
||||
padding-inline-end: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SecureBackupPanel_advanced {
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -11,6 +11,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
column-gap: $spacing-8;
|
||||
}
|
||||
|
||||
.mx_SecurityUserSettingsTab_ignoredUsers {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mx_SecurityUserSettingsTab_ignoredUser {
|
||||
margin-bottom: $spacing-4;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_VerificationShowSas_emojiSas_label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: $font-12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mx_VerificationShowSas_emojiSas_break {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32 0H0V32H32C26.4772 32 22 27.5228 22 22C22 16.4772 26.4772 12 32 12V0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 180 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M36 -4H-4V36H36V30.4722C34.9385 31.4223 33.5367 32 32 32C28.6863 32 26 29.3137 26 26C26 22.6863 28.6863 20 32 20C33.5367 20 34.9385 20.5777 36 21.5278V-4Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -3,7 +3,6 @@
|
||||
set -ex
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
DIST_VERSION=$(git describe --abbrev=0 --tags)
|
||||
|
||||
DIR=$(dirname "$0")
|
||||
|
||||
@@ -13,6 +12,8 @@ DIR=$(dirname "$0")
|
||||
if [[ $BRANCH != HEAD && ! $BRANCH =~ heads/v.+ ]]
|
||||
then
|
||||
DIST_VERSION=$("$DIR"/get-version-from-git.sh)
|
||||
else
|
||||
DIST_VERSION=$(git describe --abbrev=0 --tags)
|
||||
fi
|
||||
|
||||
DIST_VERSION=$("$DIR"/normalize-version.sh "$DIST_VERSION")
|
||||
|
||||
5
src/@types/react.d.ts
vendored
@@ -18,4 +18,9 @@ declare module "react" {
|
||||
|
||||
// Fix lazy types - https://stackoverflow.com/a/71017028
|
||||
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;
|
||||
|
||||
// Standardize defaultProps for FunctionComponent so we can write generics assuming `defaultProps` exists on ComponentType
|
||||
interface FunctionComponent {
|
||||
defaultProps?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,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 React, { type JSX, type LegacyRef, type ReactNode } from "react";
|
||||
import React, { type JSX, type Key, type LegacyRef, type ReactNode } from "react";
|
||||
import sanitizeHtml, { type IOptions } from "sanitize-html";
|
||||
import classNames from "classnames";
|
||||
import katex from "katex";
|
||||
@@ -239,7 +239,7 @@ class HtmlHighlighter extends BaseHighlighter<string> {
|
||||
|
||||
const emojiToHtmlSpan = (emoji: string): string =>
|
||||
`<span class='mx_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`;
|
||||
const emojiToJsxSpan = (emoji: string, key: number): JSX.Element => (
|
||||
const emojiToJsxSpan = (emoji: string, key: Key): JSX.Element => (
|
||||
<span key={key} className="mx_Emoji" title={unicodeToShortcode(emoji)}>
|
||||
{emoji}
|
||||
</span>
|
||||
|
||||
@@ -321,7 +321,7 @@ async function attemptOidcNativeLogin(queryParams: QueryDict): Promise<boolean>
|
||||
} catch (error) {
|
||||
logger.error("Failed to login via OIDC", error);
|
||||
|
||||
await onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
|
||||
onFailedDelegatedAuthLogin(getOidcErrorMessage(error as Error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -468,7 +468,7 @@ type TryAgainFunction = () => void;
|
||||
* @param description error description
|
||||
* @param tryAgain OPTIONAL function to call on try again button from error dialog
|
||||
*/
|
||||
async function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): Promise<void> {
|
||||
function onFailedDelegatedAuthLogin(description: string | ReactNode, tryAgain?: TryAgainFunction): void {
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("auth|oidc|error_title"),
|
||||
description,
|
||||
@@ -701,6 +701,43 @@ export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<Matr
|
||||
return doSetLoggedIn(credentials, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates an existing session by using the credentials provided. This will
|
||||
* not clear any local storage, unlike setLoggedIn().
|
||||
*
|
||||
* Stops the existing Matrix client (without clearing its data) and starts a
|
||||
* new one in its place. This additionally starts all other react-sdk services
|
||||
* which use the new Matrix client.
|
||||
*
|
||||
* If the credentials belong to a different user from the session already stored,
|
||||
* the old session will be cleared automatically.
|
||||
*
|
||||
* @param {IMatrixClientCreds} credentials The credentials to use
|
||||
*
|
||||
* @returns {Promise} promise which resolves to the new MatrixClient once it has been started
|
||||
*/
|
||||
export async function hydrateSession(credentials: IMatrixClientCreds): Promise<MatrixClient> {
|
||||
const oldUserId = MatrixClientPeg.safeGet().getUserId();
|
||||
const oldDeviceId = MatrixClientPeg.safeGet().getDeviceId();
|
||||
|
||||
stopMatrixClient(); // unsets MatrixClientPeg.get()
|
||||
localStorage.removeItem("mx_soft_logout");
|
||||
_isLoggingOut = false;
|
||||
|
||||
const overwrite = credentials.userId !== oldUserId || credentials.deviceId !== oldDeviceId;
|
||||
if (overwrite) {
|
||||
logger.warn("Clearing all data: Old session belongs to a different user/session");
|
||||
}
|
||||
|
||||
if (!credentials.pickleKey && credentials.deviceId !== undefined) {
|
||||
logger.info("Lifecycle#hydrateSession: Pickle key not provided - trying to get one");
|
||||
credentials.pickleKey =
|
||||
(await PlatformPeg.get()?.getPickleKey(credentials.userId, credentials.deviceId)) ?? undefined;
|
||||
}
|
||||
|
||||
return doSetLoggedIn(credentials, overwrite, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* When we have a authenticated via OIDC-native flow and have a refresh token
|
||||
* try to create a token refresher.
|
||||
|
||||
@@ -18,6 +18,7 @@ import defaultDispatcher from "./dispatcher/dispatcher";
|
||||
import AsyncWrapper from "./AsyncWrapper";
|
||||
import { type Defaultize } from "./@types/common";
|
||||
import { type ActionPayload } from "./dispatcher/payloads";
|
||||
import { filterBoolean } from "./utils/arrays.ts";
|
||||
|
||||
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
|
||||
const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer";
|
||||
@@ -160,13 +161,16 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
* situations like the user logging out of the app.
|
||||
*/
|
||||
public forceCloseAllModals(): void {
|
||||
for (const modal of this.modals) {
|
||||
const modals = filterBoolean([...this.modals, this.staticModal, this.priorityModal]);
|
||||
for (const modal of modals) {
|
||||
modal.deferred?.resolve([]);
|
||||
if (modal.onFinished) modal.onFinished.apply(null);
|
||||
this.emitClosed();
|
||||
}
|
||||
|
||||
this.modals = [];
|
||||
this.staticModal = null;
|
||||
this.priorityModal = null;
|
||||
this.reRender();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 React, { type Key, type RefObject, type ReactElement, type RefCallback } from "react";
|
||||
import React, { type Key, type RefObject, type ReactElement, type RefCallback, type HTMLAttributes } from "react";
|
||||
|
||||
interface IChildProps {
|
||||
style: React.CSSProperties;
|
||||
@@ -57,7 +57,8 @@ export default class NodeAnimator extends React.Component<IProps> {
|
||||
* @param {React.CSSProperties} styles a key/value pair of CSS properties
|
||||
* @returns {void}
|
||||
*/
|
||||
private applyStyles(node: HTMLElement, styles: React.CSSProperties): void {
|
||||
private applyStyles(node: HTMLElement, styles?: React.CSSProperties): void {
|
||||
if (!styles) return;
|
||||
Object.entries(styles).forEach(([property, value]) => {
|
||||
node.style[property as keyof Omit<CSSStyleDeclaration, "length" | "parentRule">] = value;
|
||||
});
|
||||
@@ -68,21 +69,22 @@ export default class NodeAnimator extends React.Component<IProps> {
|
||||
this.children = {};
|
||||
React.Children.toArray(newChildren).forEach((c) => {
|
||||
if (!isReactElement(c)) return;
|
||||
const props = c.props as HTMLAttributes<HTMLElement>;
|
||||
if (oldChildren[c.key!]) {
|
||||
const old = oldChildren[c.key!];
|
||||
const oldNode = this.nodes[old.key!];
|
||||
|
||||
if (oldNode && oldNode.style.left !== c.props.style.left) {
|
||||
this.applyStyles(oldNode, { left: c.props.style.left });
|
||||
if (oldNode && props.style && oldNode.style.left !== props.style.left) {
|
||||
this.applyStyles(oldNode, { left: props.style.left });
|
||||
}
|
||||
// clone the old element with the props (and children) of the new element
|
||||
// so prop updates are still received by the children.
|
||||
this.children[c.key!] = React.cloneElement(old, c.props, c.props.children);
|
||||
this.children[c.key!] = React.cloneElement(old, props, props.children);
|
||||
} else {
|
||||
// new element. If we have a startStyle, use that as the style and go through
|
||||
// the enter animations
|
||||
const newProps: Partial<IChildProps> = {};
|
||||
const restingStyle = c.props.style;
|
||||
const restingStyle = props.style;
|
||||
|
||||
const startStyles = this.props.startStyles;
|
||||
if (startStyles.length > 0) {
|
||||
@@ -97,7 +99,7 @@ export default class NodeAnimator extends React.Component<IProps> {
|
||||
});
|
||||
}
|
||||
|
||||
private collectNode(k: Key, domNode: HTMLElement | null, restingStyle: React.CSSProperties): void {
|
||||
private collectNode(k: Key, domNode: HTMLElement | null, restingStyle?: React.CSSProperties): void {
|
||||
const key = typeof k === "bigint" ? Number(k) : k;
|
||||
if (domNode && this.nodes[key] === undefined && this.props.startStyles.length > 0) {
|
||||
const startStyles = this.props.startStyles;
|
||||
|
||||
37
src/Rooms.ts
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { type Room, EventType, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import AliasCustomisations from "./customisations/Alias";
|
||||
import { filterValidMDirect } from "./utils/dm/filterValidMDirect.ts";
|
||||
|
||||
/**
|
||||
* Given a room object, return the alias we should use for it,
|
||||
@@ -56,39 +57,23 @@ export async function setDMRoom(client: MatrixClient, roomId: string, userId: st
|
||||
if (client.isGuest()) return;
|
||||
|
||||
const mDirectEvent = client.getAccountData(EventType.Direct);
|
||||
const currentContent = mDirectEvent?.getContent() || {};
|
||||
const { filteredContent } = filterValidMDirect(mDirectEvent?.getContent() ?? {});
|
||||
|
||||
const dmRoomMap = new Map(Object.entries(currentContent));
|
||||
let modified = false;
|
||||
|
||||
// remove it from the lists of any others users
|
||||
// (it can only be a DM room for one person)
|
||||
for (const thisUserId of dmRoomMap.keys()) {
|
||||
const roomList = dmRoomMap.get(thisUserId) || [];
|
||||
|
||||
if (thisUserId != userId) {
|
||||
const indexOfRoom = roomList.indexOf(roomId);
|
||||
if (indexOfRoom > -1) {
|
||||
roomList.splice(indexOfRoom, 1);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
// remove it from the lists of all users (it can only be a DM room for one person)
|
||||
for (const thisUserId in filteredContent) {
|
||||
if (!filteredContent[thisUserId]) continue;
|
||||
filteredContent[thisUserId] = filteredContent[thisUserId].filter((room) => room !== roomId);
|
||||
}
|
||||
|
||||
// now add it, if it's not already there
|
||||
// now add it if the caller asked for it to be a DM room
|
||||
if (userId) {
|
||||
const roomList = dmRoomMap.get(userId) || [];
|
||||
if (roomList.indexOf(roomId) == -1) {
|
||||
roomList.push(roomId);
|
||||
modified = true;
|
||||
if (!filteredContent[userId]) {
|
||||
filteredContent[userId] = [];
|
||||
}
|
||||
dmRoomMap.set(userId, roomList);
|
||||
filteredContent[userId].push(roomId);
|
||||
}
|
||||
|
||||
// prevent unnecessary calls to setAccountData
|
||||
if (!modified) return;
|
||||
|
||||
await client.setAccountData(EventType.Direct, Object.fromEntries(dmRoomMap));
|
||||
await client.setAccountData(EventType.Direct, filteredContent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -718,4 +718,8 @@ export interface SearchInfo {
|
||||
* The total count of matching results as returned by the backend.
|
||||
*/
|
||||
count?: number;
|
||||
/**
|
||||
* Describe the error if any occured.
|
||||
*/
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
scrollIntoView,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
|
||||
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { type ReactElement } from "react";
|
||||
import { type ReactElement, type RefAttributes, type HTMLAttributes } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import CommandProvider from "./CommandProvider";
|
||||
@@ -31,7 +31,7 @@ export interface ICompletion {
|
||||
type?: "at-room" | "command" | "community" | "room" | "user";
|
||||
completion: string;
|
||||
completionId?: string;
|
||||
component: ReactElement;
|
||||
component: ReactElement<RefAttributes<HTMLElement> & HTMLAttributes<HTMLElement>>;
|
||||
range: ISelectionRange;
|
||||
command?: string;
|
||||
suffix?: string;
|
||||
|
||||
@@ -21,8 +21,6 @@ export type IProps<T extends keyof JSX.IntrinsicElements> = Omit<AutoHideScrollb
|
||||
// scroll horizontally rather than vertically. This should only be used on components
|
||||
// with no vertical scroll opportunity.
|
||||
verticalScrollsHorizontally?: boolean;
|
||||
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
interface IState {
|
||||
|
||||
@@ -165,12 +165,6 @@ interface IProps {
|
||||
initialScreenAfterLogin?: IScreen;
|
||||
// displayname, if any, to set on the device when logging in/registering.
|
||||
defaultDeviceDisplayName?: string;
|
||||
|
||||
// Used by tests, this function is called when session initialisation starts
|
||||
// with a promise that resolves or rejects once the initialiation process
|
||||
// has finished, so that tests can wait for this to avoid them executing over
|
||||
// each other.
|
||||
initPromiseCallback?: (p: Promise<void>) => void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
@@ -291,9 +285,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
*/
|
||||
private startInitSession = (): void => {
|
||||
const initProm = this.initSession();
|
||||
if (this.props.initPromiseCallback) {
|
||||
this.props.initPromiseCallback(initProm);
|
||||
}
|
||||
|
||||
initProm.catch((err) => {
|
||||
// TODO: show an error screen, rather than a spinner of doom
|
||||
@@ -711,36 +702,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case "copy_room":
|
||||
this.copyRoom(payload.room_id);
|
||||
break;
|
||||
case "reject_invite":
|
||||
Modal.createDialog(QuestionDialog, {
|
||||
title: _t("reject_invitation_dialog|title"),
|
||||
description: _t("reject_invitation_dialog|confirmation"),
|
||||
onFinished: (confirm) => {
|
||||
if (confirm) {
|
||||
// FIXME: controller shouldn't be loading a view :(
|
||||
const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner");
|
||||
|
||||
MatrixClientPeg.safeGet()
|
||||
.leave(payload.room_id)
|
||||
.then(
|
||||
() => {
|
||||
modal.close();
|
||||
if (this.state.currentRoomId === payload.room_id) {
|
||||
dis.dispatch({ action: Action.ViewHomePage });
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
modal.close();
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("reject_invitation_dialog|failed"),
|
||||
description: err.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "view_user_info":
|
||||
this.viewUser(payload.userId, payload.subAction);
|
||||
break;
|
||||
@@ -1032,10 +993,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
// Wait for the first sync to complete so that if a room does have an alias,
|
||||
// it would have been retrieved.
|
||||
if (!this.firstSyncComplete) {
|
||||
if (!this.firstSyncPromise) {
|
||||
logger.warn("Cannot view a room before first sync. room_id:", roomInfo.room_id);
|
||||
return;
|
||||
}
|
||||
await this.firstSyncPromise.promise;
|
||||
}
|
||||
|
||||
@@ -1146,8 +1103,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
private viewUser(userId: string, subAction: string): void {
|
||||
// Wait for the first sync so that `getRoom` gives us a room object if it's
|
||||
// in the sync response
|
||||
const waitForSync = this.firstSyncPromise ? this.firstSyncPromise.promise : Promise.resolve();
|
||||
waitForSync.then(() => {
|
||||
this.firstSyncPromise.promise.then(() => {
|
||||
if (subAction === "chat") {
|
||||
this.chatCreateOrReuse(userId);
|
||||
return;
|
||||
@@ -1510,11 +1466,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
* (useful for setting listeners)
|
||||
*/
|
||||
private onWillStartClient(): void {
|
||||
// reset the 'have completed first sync' flag,
|
||||
// since we're about to start the client and therefore about
|
||||
// to do the first sync
|
||||
// Reset the 'have completed first sync' flag,
|
||||
// since we're about to start the client and therefore about to do the first sync
|
||||
// We resolve the existing promise with the new one to update any existing listeners
|
||||
if (!this.firstSyncComplete) {
|
||||
const firstSyncPromise = defer<void>();
|
||||
this.firstSyncPromise.resolve(firstSyncPromise.promise);
|
||||
this.firstSyncPromise = firstSyncPromise;
|
||||
} else {
|
||||
this.firstSyncPromise = defer();
|
||||
}
|
||||
this.firstSyncComplete = false;
|
||||
this.firstSyncPromise = defer();
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
|
||||
// Allow the JS SDK to reap timeline events. This reduces the amount of
|
||||
|
||||
@@ -21,8 +21,6 @@ import { _t } from "../../languageHandler";
|
||||
import { haveRendererForEvent } from "../../events/EventTileFactory";
|
||||
import SearchResultTile from "../views/rooms/SearchResultTile";
|
||||
import { searchPagination, SearchScope } from "../../Searching";
|
||||
import Modal from "../../Modal";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import type ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
@@ -45,7 +43,7 @@ interface Props {
|
||||
abortController?: AbortController;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
className: string;
|
||||
onUpdate(inProgress: boolean, results: ISearchResults | null): void;
|
||||
onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void;
|
||||
}
|
||||
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
@@ -70,7 +68,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||
|
||||
const handleSearchResult = useCallback(
|
||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||
onUpdate(true, null);
|
||||
onUpdate(true, null, null);
|
||||
|
||||
return searchPromise.then(
|
||||
async (results): Promise<boolean> => {
|
||||
@@ -116,7 +114,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
onUpdate(false, results);
|
||||
onUpdate(false, results, null);
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
@@ -125,11 +123,7 @@ export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||
return false;
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("error_dialog|search_failed|title"),
|
||||
description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"),
|
||||
});
|
||||
onUpdate(false, null);
|
||||
onUpdate(false, null, error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,6 +134,7 @@ import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
||||
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
||||
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
|
||||
import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext";
|
||||
import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog";
|
||||
|
||||
const DEBUG = false;
|
||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||
@@ -1715,11 +1716,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
this.onSearch(this.state.search?.term ?? "", scope);
|
||||
};
|
||||
|
||||
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => {
|
||||
private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null, error: Error | null): void => {
|
||||
this.setState({
|
||||
search: {
|
||||
...this.state.search!,
|
||||
count: searchResults?.count,
|
||||
error: error ?? undefined,
|
||||
inProgress,
|
||||
},
|
||||
});
|
||||
@@ -1732,48 +1734,61 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onRejectButtonClicked = (): void => {
|
||||
const roomId = this.getRoomId();
|
||||
if (!roomId) return;
|
||||
private onDeclineAndBlockButtonClicked = async (): Promise<void> => {
|
||||
if (!this.state.room || !this.context.client) return;
|
||||
const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, {
|
||||
roomName: this.state.room.name,
|
||||
}).finished;
|
||||
if (!shouldReject) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
this.context.client?.leave(roomId).then(
|
||||
() => {
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
logger.error(`Failed to reject invite: ${error}`);
|
||||
|
||||
const msg = error.message ? error.message : JSON.stringify(error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("room|failed_reject_invite"),
|
||||
description: msg,
|
||||
});
|
||||
const actions: Promise<unknown>[] = [];
|
||||
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (ignoreUser) {
|
||||
const myMember = this.state.room.getMember(this.context.client!.getSafeUserId());
|
||||
const inviteEvent = myMember!.events.member;
|
||||
const ignoredUsers = this.context.client.getIgnoredUsers();
|
||||
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
|
||||
actions.push(this.context.client.setIgnoredUsers(ignoredUsers));
|
||||
}
|
||||
|
||||
if (reportRoom !== false) {
|
||||
actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom));
|
||||
}
|
||||
|
||||
actions.push(this.context.client.leave(this.state.room.roomId));
|
||||
try {
|
||||
await Promise.all(actions);
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reject invite: ${error}`);
|
||||
|
||||
const msg = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("room|failed_reject_invite"),
|
||||
description: msg,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onRejectAndIgnoreClick = async (): Promise<void> => {
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
|
||||
private onDeclineButtonClicked = async (): Promise<void> => {
|
||||
if (!this.state.room || !this.context.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const myMember = this.state.room!.getMember(this.context.client!.getSafeUserId());
|
||||
const inviteEvent = myMember!.events.member;
|
||||
const ignoredUsers = this.context.client!.getIgnoredUsers();
|
||||
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
|
||||
await this.context.client!.setIgnoredUsers(ignoredUsers);
|
||||
|
||||
await this.context.client!.leave(this.state.roomId!);
|
||||
await this.context.client.leave(this.state.room.roomId);
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
@@ -2126,7 +2141,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
@@ -2154,7 +2169,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewCard
|
||||
room={this.state.room}
|
||||
onJoinButtonClicked={this.onJoinButtonClicked}
|
||||
onRejectButtonClicked={this.onRejectButtonClicked}
|
||||
onRejectButtonClicked={this.onDeclineButtonClicked}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
@@ -2196,8 +2211,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectButtonClicked}
|
||||
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
|
||||
onDeclineClick={this.onDeclineButtonClicked}
|
||||
onDeclineAndBlockClick={this.onDeclineAndBlockButtonClicked}
|
||||
promptRejectionOptions={true}
|
||||
inviterName={inviterName}
|
||||
canPreview={false}
|
||||
joining={this.state.joining}
|
||||
@@ -2312,7 +2328,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
|
||||
promptRejectionOptions={true}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
@@ -2350,7 +2367,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
onRejectButtonClicked={
|
||||
this.props.threepidInvite
|
||||
? this.onRejectThreepidInviteButtonClicked
|
||||
: this.onRejectButtonClicked
|
||||
: this.onDeclineButtonClicked
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,14 +27,14 @@ export class Tab<T extends string> {
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} body The JSX for the tab container.
|
||||
* @param {JSX.Element} body The JSX for the tab container.
|
||||
* @param {string} screenName The screen name to report to Posthog.
|
||||
*/
|
||||
public constructor(
|
||||
public readonly id: T,
|
||||
public readonly label: TranslationKey,
|
||||
public readonly icon: string | JSX.Element | null,
|
||||
public readonly body: React.ReactNode,
|
||||
public readonly body: JSX.Element,
|
||||
public readonly screenName?: ScreenName,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
return;
|
||||
}
|
||||
|
||||
Lifecycle.setLoggedIn(credentials).catch((e) => {
|
||||
Lifecycle.hydrateSession(credentials).catch((e) => {
|
||||
logger.error(e);
|
||||
this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") });
|
||||
});
|
||||
@@ -204,7 +204,7 @@ export default class SoftLogout extends React.Component<IProps, IState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Lifecycle.setLoggedIn(credentials)
|
||||
return Lifecycle.hydrateSession(credentials)
|
||||
.then(() => {
|
||||
if (this.props.onTokenLoginCompleted) {
|
||||
this.props.onTokenLoginCompleted();
|
||||
|
||||
@@ -6,10 +6,12 @@ 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 { createContext, type Dispatch, type ReducerAction, type ReducerState } from "react";
|
||||
import { createContext, type Dispatch, type Reducer, type ReducerState } from "react";
|
||||
|
||||
import type { AuthHeaderReducer } from "./AuthHeaderProvider";
|
||||
|
||||
type ReducerAction<R extends Reducer<any, any>> = R extends Reducer<any, infer A> ? A : never;
|
||||
|
||||
interface AuthHeaderContextType {
|
||||
state: ReducerState<AuthHeaderReducer>;
|
||||
dispatch: Dispatch<ReducerAction<AuthHeaderReducer>>;
|
||||
|
||||
@@ -25,7 +25,7 @@ interface AuthHeaderAction {
|
||||
export type AuthHeaderReducer = Reducer<ComponentProps<typeof AuthHeaderModifier>[], AuthHeaderAction>;
|
||||
|
||||
export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
const [state, dispatch] = useReducer<AuthHeaderReducer>(
|
||||
const [state, dispatch] = useReducer<ComponentProps<typeof AuthHeaderModifier>[], [AuthHeaderAction]>(
|
||||
(state: ComponentProps<typeof AuthHeaderModifier>[], action: AuthHeaderAction) => {
|
||||
switch (action.type) {
|
||||
case AuthHeaderActionType.Add:
|
||||
|
||||
144
src/components/viewmodels/avatars/RoomAvatarViewModel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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 {
|
||||
EventType,
|
||||
JoinRule,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type User,
|
||||
UserEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
|
||||
import { BUSY_PRESENCE_NAME } from "../../views/rooms/PresenceLabel";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
|
||||
/**
|
||||
* The presence of a user in a DM room.
|
||||
* - "online": The user is online.
|
||||
* - "offline": The user is offline.
|
||||
* - "busy": The user is busy.
|
||||
* - "unavailable": the presence is unavailable.
|
||||
* - null: the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
export type Presence = "online" | "offline" | "busy" | "unavailable" | null;
|
||||
|
||||
export interface RoomAvatarViewState {
|
||||
/**
|
||||
* Whether the room avatar has a decoration.
|
||||
* A decoration can be a public or a video call icon or an indicator of presence.
|
||||
*/
|
||||
hasDecoration: boolean;
|
||||
/**
|
||||
* Whether the room is public.
|
||||
*/
|
||||
isPublic: boolean;
|
||||
/**
|
||||
* Whether the room is a video room.
|
||||
*/
|
||||
isVideoRoom: boolean;
|
||||
/**
|
||||
* The presence of the user in the DM room.
|
||||
* If null, the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
presence: Presence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the state of the room avatar.
|
||||
* @param room
|
||||
*/
|
||||
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
const presence = useDMPresence(room);
|
||||
const isPublic = useIsPublic(room);
|
||||
|
||||
const hasDecoration = isPublic || isVideoRoom || presence !== null;
|
||||
|
||||
return { hasDecoration, isPublic, isVideoRoom, presence };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listening to the room join rules.
|
||||
* Return true if the room is public.
|
||||
* @param room
|
||||
*/
|
||||
function useIsPublic(room: Room): boolean {
|
||||
const [isPublic, setIsPublic] = useState(isRoomPublic(room));
|
||||
// We don't use `useTypedEventEmitterState` because we don't want to update `isPublic` value at every `RoomEvent.Timeline` event.
|
||||
useTypedEventEmitter(room, RoomEvent.Timeline, (ev: MatrixEvent, _room: Room) => {
|
||||
if (room.roomId !== _room.roomId) return;
|
||||
if (ev.getType() !== EventType.RoomJoinRules && ev.getType() !== EventType.RoomMember) return;
|
||||
|
||||
setIsPublic(isRoomPublic(_room));
|
||||
});
|
||||
|
||||
// Reset the value when the room changes
|
||||
useEffect(() => {
|
||||
setIsPublic(isRoomPublic(room));
|
||||
}, [room]);
|
||||
|
||||
return isPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the room is public.
|
||||
* @param room
|
||||
*/
|
||||
function isRoomPublic(room: Room): boolean {
|
||||
return room.getJoinRule() === JoinRule.Public;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listening to the presence of the DM user.
|
||||
* @param room
|
||||
*/
|
||||
function useDMPresence(room: Room): Presence {
|
||||
const dmUser = getDMUser(room);
|
||||
const [presence, setPresence] = useState<Presence>(getPresence(dmUser));
|
||||
useTypedEventEmitter(dmUser, UserEvent.Presence, () => setPresence(getPresence(dmUser)));
|
||||
useTypedEventEmitter(dmUser, UserEvent.CurrentlyActive, () => setPresence(getPresence(dmUser)));
|
||||
|
||||
return presence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DM user of the room.
|
||||
* Return undefined if the room is not a DM room, if we can't find the user or if the presence is not enabled.
|
||||
* @param room
|
||||
* @returns found user
|
||||
*/
|
||||
function getDMUser(room: Room): User | undefined {
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (!otherUserId) return;
|
||||
if (getJoinedNonFunctionalMembers(room).length !== 2) return;
|
||||
if (!isPresenceEnabled(room.client)) return;
|
||||
|
||||
return room.client.getUser(otherUserId) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presence of the DM user.
|
||||
* @param dmUser
|
||||
*/
|
||||
function getPresence(dmUser: User | undefined): Presence {
|
||||
if (!dmUser) return null;
|
||||
if (BUSY_PRESENCE_NAME.matches(dmUser.presence)) return "busy";
|
||||
|
||||
const isOnline = dmUser.currentlyActive || dmUser.presence === "online";
|
||||
if (isOnline) return "online";
|
||||
|
||||
if (dmUser.presence === "offline") return "offline";
|
||||
if (dmUser.presence === "unavailable") return "unavailable";
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -128,8 +128,8 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
|
||||
const isSpaceRoom = Boolean(activeSpace);
|
||||
|
||||
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace);
|
||||
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
|
||||
const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
|
||||
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom;
|
||||
const displayComposeMenu = canCreateRoom;
|
||||
const displaySpaceMenu = isSpaceRoom;
|
||||
const canInviteInSpace = Boolean(
|
||||
activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
|
||||
import { hasAccessToOptionsMenu } from "./utils";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
|
||||
@@ -21,12 +21,18 @@ import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||
import { RoomNotifState } from "../../../RoomNotifs";
|
||||
import { useNotificationState } from "../../../hooks/useRoomNotificationState";
|
||||
|
||||
export interface RoomListItemMenuViewState {
|
||||
/**
|
||||
* Whether the more options menu should be shown.
|
||||
*/
|
||||
showMoreOptionsMenu: boolean;
|
||||
/**
|
||||
* Whether the notification menu should be shown.
|
||||
*/
|
||||
showNotificationMenu: boolean;
|
||||
/**
|
||||
* Whether the room is a favourite room.
|
||||
*/
|
||||
@@ -47,6 +53,22 @@ export interface RoomListItemMenuViewState {
|
||||
* Can mark the room as unread.
|
||||
*/
|
||||
canMarkAsUnread: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages.
|
||||
*/
|
||||
isNotificationAllMessage: boolean;
|
||||
/**
|
||||
* Whether the notification is set to all messages loud.
|
||||
*/
|
||||
isNotificationAllMessageLoud: boolean;
|
||||
/**
|
||||
* Whether the notification is set to mentions and keywords only.
|
||||
*/
|
||||
isNotificationMentionOnly: boolean;
|
||||
/**
|
||||
* Whether the notification is muted.
|
||||
*/
|
||||
isNotificationMute: boolean;
|
||||
/**
|
||||
* Mark the room as read.
|
||||
* @param evt
|
||||
@@ -81,6 +103,11 @@ export interface RoomListItemMenuViewState {
|
||||
* @param evt
|
||||
*/
|
||||
leaveRoom: (evt: Event) => void;
|
||||
/**
|
||||
* Set the room notification state.
|
||||
* @param state
|
||||
*/
|
||||
setRoomNotifState: (state: RoomNotifState) => void;
|
||||
}
|
||||
|
||||
export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState {
|
||||
@@ -88,12 +115,13 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const { level: notificationLevel } = useUnreadNotifications(room);
|
||||
|
||||
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
|
||||
|
||||
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
|
||||
const showMoreOptionsMenu = hasAccessToOptionsMenu(room);
|
||||
const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
|
||||
|
||||
const canMarkAsRead = notificationLevel > NotificationLevel.None;
|
||||
const canMarkAsUnread = !canMarkAsRead && !isArchived;
|
||||
|
||||
@@ -101,6 +129,12 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers);
|
||||
const canCopyRoomLink = !isDm;
|
||||
|
||||
const [roomNotifState, setRoomNotifState] = useNotificationState(room);
|
||||
const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages;
|
||||
const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud;
|
||||
const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly;
|
||||
const isNotificationMute = roomNotifState === RoomNotifState.Mute;
|
||||
|
||||
// Actions
|
||||
|
||||
const markAsRead = useCallback(
|
||||
@@ -164,11 +198,16 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
|
||||
return {
|
||||
showMoreOptionsMenu,
|
||||
showNotificationMenu,
|
||||
isFavourite,
|
||||
canInvite,
|
||||
canCopyRoomLink,
|
||||
canMarkAsRead,
|
||||
canMarkAsUnread,
|
||||
isNotificationAllMessage,
|
||||
isNotificationAllMessageLoud,
|
||||
isNotificationMentionOnly,
|
||||
isNotificationMute,
|
||||
markAsRead,
|
||||
markAsUnread,
|
||||
toggleFavorite,
|
||||
@@ -176,5 +215,6 @@ export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewSt
|
||||
invite,
|
||||
copyRoomLink,
|
||||
leaveRoom,
|
||||
setRoomNotifState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { hasAccessToOptionsMenu } from "./utils";
|
||||
import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { type ConnectionState } from "../../../models/Call";
|
||||
|
||||
export interface RoomListItemViewState {
|
||||
/**
|
||||
@@ -33,6 +38,23 @@ export interface RoomListItemViewState {
|
||||
* The notification state of the room.
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* Whether the room should be bolded.
|
||||
*/
|
||||
isBold: boolean;
|
||||
/**
|
||||
* Whether the room is a video room
|
||||
*/
|
||||
isVideoRoom: boolean;
|
||||
/**
|
||||
* The connection state of the call.
|
||||
* `null` if there is no call in the room.
|
||||
*/
|
||||
callConnectionState: ConnectionState | null;
|
||||
/**
|
||||
* Whether there are participants in the call.
|
||||
*/
|
||||
hasParticipantInCall: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,10 +62,23 @@ export interface RoomListItemViewState {
|
||||
* @see {@link RoomListItemViewState} for more information about what this view model returns.
|
||||
*/
|
||||
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
// incoming: Check notification menu rights
|
||||
const showHoverMenu = hasAccessToOptionsMenu(room);
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
|
||||
const showHoverMenu =
|
||||
hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
|
||||
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
|
||||
const a11yLabel = getA11yLabel(room, notificationState);
|
||||
const isBold = notificationState.hasAnyNotificationOrActivity;
|
||||
|
||||
// Video room
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
// EC video call or video room
|
||||
const call = useCall(room.roomId);
|
||||
const connectionState = useConnectionState(call);
|
||||
const hasParticipantInCall = useParticipantCount(call) > 0;
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -60,6 +95,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
showHoverMenu,
|
||||
openRoom,
|
||||
a11yLabel,
|
||||
isBold,
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useStickyRoomList } from "./useStickyRoomList";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
* Whether the list of rooms is being loaded.
|
||||
*/
|
||||
isLoadingRooms: boolean;
|
||||
|
||||
/**
|
||||
* A list of rooms to be displayed in the left panel.
|
||||
*/
|
||||
@@ -98,6 +103,7 @@ export interface RoomListViewState {
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms: filteredRooms,
|
||||
@@ -120,6 +126,7 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
rooms,
|
||||
canCreateRoom,
|
||||
createRoom,
|
||||
|
||||
@@ -13,6 +13,8 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
|
||||
|
||||
/**
|
||||
* Provides information about a primary filter.
|
||||
@@ -33,6 +35,7 @@ export interface PrimaryFilter {
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
isLoadingRooms: boolean;
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
@@ -113,12 +116,19 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
);
|
||||
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
|
||||
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
setRooms(newRooms);
|
||||
}, []);
|
||||
|
||||
// Reset filters when active space changes
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
|
||||
setPrimaryFilter(undefined);
|
||||
activateSecondaryFilter(SecondaryFilters.AllActivity);
|
||||
});
|
||||
|
||||
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
|
||||
array.filter((f) => f !== undefined) as FilterKey[];
|
||||
|
||||
@@ -127,6 +137,7 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
};
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
setIsLoadingRooms(false);
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
});
|
||||
@@ -186,5 +197,12 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
return {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has access to the notification menu.
|
||||
* @param room
|
||||
* @param isGuest
|
||||
* @param isArchived
|
||||
*/
|
||||
export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean {
|
||||
return !isGuest && !isArchived && hasAccessToOptionsMenu(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room
|
||||
* @param space - The space to create the room in
|
||||
|
||||
@@ -79,6 +79,9 @@ function tooltipText(variant: Icon): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link RoomAvatarView} instead.
|
||||
*/
|
||||
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
|
||||
private _dmUser: User | null = null;
|
||||
private isUnmounted = false;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CardContext } from "../right_panel/context";
|
||||
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
|
||||
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx";
|
||||
|
||||
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
|
||||
member: RoomMember | null;
|
||||
@@ -47,6 +48,7 @@ function MemberAvatar(
|
||||
}: IProps,
|
||||
ref: Ref<HTMLElement>,
|
||||
): JSX.Element {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const card = useContext(CardContext);
|
||||
|
||||
const member = useRoomMemberProfile({
|
||||
@@ -60,7 +62,7 @@ function MemberAvatar(
|
||||
let imageUrl: string | null | undefined;
|
||||
if (member?.name) {
|
||||
if (member.getMxcAvatarUrl()) {
|
||||
imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "").getThumbnailOfSourceHttp(
|
||||
imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "", cli).getThumbnailOfSourceHttp(
|
||||
parseInt(size, 10),
|
||||
parseInt(size, 10),
|
||||
resizeMethod,
|
||||
|
||||
@@ -144,7 +144,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
|
||||
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
|
||||
const roomName = room?.name ?? oobData.name ?? "?";
|
||||
|
||||
return (
|
||||
|
||||
127
src/components/views/avatars/RoomAvatarView.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||
import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
|
||||
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 RoomAvatar from "./RoomAvatar";
|
||||
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
interface RoomAvatarViewProps {
|
||||
/**
|
||||
* The room to display the avatar for.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display the avatar of a room.
|
||||
* Currently only 32px size is supported.
|
||||
*/
|
||||
export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
|
||||
const vm = useRoomAvatarViewModel(room);
|
||||
// No decoration, we just show the avatar
|
||||
if (!vm.hasDecoration) return <RoomAvatar size="32px" room={room} />;
|
||||
|
||||
return (
|
||||
<div className="mx_RoomAvatarView">
|
||||
<RoomAvatar
|
||||
className={classNames("mx_RoomAvatarView_RoomAvatar", {
|
||||
// Presence indicator and video/public icons don't have the same size
|
||||
// We use different masks
|
||||
mx_RoomAvatarView_RoomAvatar_icon: vm.isVideoRoom || vm.isPublic,
|
||||
mx_RoomAvatarView_RoomAvatar_presence: Boolean(vm.presence),
|
||||
})}
|
||||
size="32px"
|
||||
room={room}
|
||||
/>
|
||||
|
||||
{/* If the room is a public video room, we prefer to display only the video icon */}
|
||||
{vm.isPublic && !vm.isVideoRoom && (
|
||||
<PublicIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomAvatarView_icon"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
aria-label={_t("room|header|room_is_public")}
|
||||
/>
|
||||
)}
|
||||
{vm.isVideoRoom && (
|
||||
<VideoIcon
|
||||
width="16px"
|
||||
height="16px"
|
||||
className="mx_RoomAvatarView_icon"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
aria-label={_t("room|video_room")}
|
||||
/>
|
||||
)}
|
||||
{vm.presence && <PresenceDecoration presence={vm.presence} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PresenceDecorationProps = {
|
||||
/**
|
||||
* The presence of the user in the DM room.
|
||||
*/
|
||||
presence: NonNullable<Presence>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component to display the presence of a user in a DM room.
|
||||
*/
|
||||
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
|
||||
switch (presence) {
|
||||
case "online":
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
height="8px"
|
||||
className="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-accent-primary)"
|
||||
aria-label={_t("presence|online")}
|
||||
/>
|
||||
);
|
||||
case "unavailable":
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
height="8px"
|
||||
className="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-quaternary)"
|
||||
aria-label={_t("presence|away")}
|
||||
/>
|
||||
);
|
||||
case "offline":
|
||||
return (
|
||||
<OfflineIcon
|
||||
width="8px"
|
||||
height="8px"
|
||||
className="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
aria-label={_t("presence|offline")}
|
||||
/>
|
||||
);
|
||||
case "busy":
|
||||
return (
|
||||
<BusyIcon
|
||||
width="8px"
|
||||
height="8px"
|
||||
className="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
aria-label={_t("presence|busy")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ChangeEventHandler, useCallback, useState } from "react";
|
||||
import { Field, Label, Root } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const DeclineAndBlockInviteDialog: React.FunctionComponent<IProps> = ({ onFinished, roomName }) => {
|
||||
const [shouldReport, setShouldReport] = useState<boolean>(false);
|
||||
const [ignoreUser, setIgnoreUser] = useState<boolean>(false);
|
||||
|
||||
const [reportReason, setReportReason] = useState<string>("");
|
||||
const reportReasonChanged = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => setReportReason(e.target.value),
|
||||
[setReportReason],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => onFinished(false, false, false), [onFinished]);
|
||||
const onOk = useCallback(
|
||||
() => onFinished(true, ignoreUser, shouldReport ? reportReason : false),
|
||||
[onFinished, ignoreUser, shouldReport, reportReason],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_DeclineAndBlockInviteDialog"
|
||||
onFinished={onCancel}
|
||||
title={_t("decline_invitation_dialog|title")}
|
||||
contentId="mx_Dialog_content"
|
||||
>
|
||||
<Root>
|
||||
<p>{_t("decline_invitation_dialog|confirm", { roomName })}</p>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("report_content|ignore_user")}
|
||||
onChange={setIgnoreUser}
|
||||
caption={_t("decline_invitation_dialog|ignore_user_help")}
|
||||
value={ignoreUser}
|
||||
/>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("action|report_room")}
|
||||
onChange={setShouldReport}
|
||||
caption={_t("decline_invitation_dialog|report_room_description")}
|
||||
value={shouldReport}
|
||||
/>
|
||||
<Field name="report-reason" aria-disabled={!shouldReport}>
|
||||
<Label htmlFor="mx_DeclineAndBlockInviteDialog_reason">
|
||||
{_t("room_settings|permissions|ban_reason")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="mx_DeclineAndBlockInviteDialog_reason"
|
||||
className="mx_RoomReportTextArea"
|
||||
placeholder={_t("decline_invitation_dialog|reason_description")}
|
||||
rows={5}
|
||||
onChange={reportReasonChanged}
|
||||
value={shouldReport ? reportReason : ""}
|
||||
disabled={!shouldReport}
|
||||
/>
|
||||
</Field>
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|decline_invite")}
|
||||
primaryButtonClass="danger"
|
||||
cancelButton={_t("action|cancel")}
|
||||
onPrimaryButtonClick={onOk}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Root>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
@@ -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 React, { createRef } from "react";
|
||||
import React, { createRef, type RefObject } from "react";
|
||||
import { type DialogContent, type DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
|
||||
@@ -27,10 +27,10 @@ interface IState extends IScrollableBaseState {
|
||||
// nothing special
|
||||
}
|
||||
|
||||
export class ModuleUiDialog<P extends DialogProps, C extends DialogContent<P>> extends ScrollableBaseModal<
|
||||
IProps<P, C>,
|
||||
IState
|
||||
> {
|
||||
export class ModuleUiDialog<
|
||||
P extends DialogProps = DialogProps,
|
||||
C extends DialogContent<P> = DialogContent<P>,
|
||||
> extends ScrollableBaseModal<IProps<P, C>, IState> {
|
||||
private contentRef = createRef<C>();
|
||||
|
||||
public constructor(props: IProps<P, C>) {
|
||||
@@ -74,6 +74,11 @@ export class ModuleUiDialog<P extends DialogProps, C extends DialogContent<P>> e
|
||||
...dialogProps,
|
||||
} as unknown as P;
|
||||
|
||||
return <div className="mx_ModuleUiDialog">{this.props.contentFactory(contentProps, this.contentRef)}</div>;
|
||||
// XXX: we have to fudge the types here a little as the react-sdk-module-api lacks React 19 support
|
||||
return (
|
||||
<div className="mx_ModuleUiDialog">
|
||||
{this.props.contentFactory(contentProps, this.contentRef as RefObject<C>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ChangeEventHandler, useCallback, useState } from "react";
|
||||
import { Root, Field, Label, InlineSpinner, ErrorMessage } from "@vector-im/compound-web";
|
||||
import { Root, Field, Label, InlineSpinner, ErrorMessage, HelpMessage } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
@@ -14,10 +14,11 @@ import Markdown from "../../../Markdown";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
onFinished(complete: boolean): void;
|
||||
onFinished(leave: boolean): void;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -27,27 +28,26 @@ interface IProps {
|
||||
export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished }) {
|
||||
const [error, setErr] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [leaveRoom, setLeaveRoom] = useState(false);
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
|
||||
const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []);
|
||||
const onCancel = useCallback(() => onFinished(sent), [sent, onFinished]);
|
||||
const onCancel = useCallback(() => onFinished(false), [onFinished]);
|
||||
const onSubmit = useCallback(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.reportRoom(roomId, reason);
|
||||
setSent(true);
|
||||
onFinished(leaveRoom);
|
||||
} catch (ex) {
|
||||
setBusy(false);
|
||||
if (ex instanceof Error) {
|
||||
setErr(ex.message);
|
||||
} else {
|
||||
setErr("Unknown error");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [roomId, reason, client]);
|
||||
}, [roomId, reason, client, leaveRoom, onFinished]);
|
||||
|
||||
const adminMessageMD = SdkConfig.getObject("report_event")?.get("admin_message_md", "adminMessageMD");
|
||||
let adminMessage: JSX.Element | undefined;
|
||||
@@ -59,37 +59,39 @@ export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_ReportRoomDialog"
|
||||
onFinished={() => onFinished(sent)}
|
||||
title={_t("report_room|title")}
|
||||
onFinished={onCancel}
|
||||
title={_t("action|report_room")}
|
||||
contentId="mx_ReportEventDialog"
|
||||
>
|
||||
{sent && <p>{_t("report_room|sent")}</p>}
|
||||
{!sent && (
|
||||
<Root id="mx_ReportEventDialog" onSubmit={onSubmit}>
|
||||
<p>{_t("report_room|description")}</p>
|
||||
{adminMessage}
|
||||
<Field name="reason">
|
||||
<Label htmlFor="mx_ReportRoomDialog_reason">{_t("room_settings|permissions|ban_reason")}</Label>
|
||||
<textarea
|
||||
id="mx_ReportRoomDialog_reason"
|
||||
placeholder={_t("report_room|reason_placeholder")}
|
||||
rows={5}
|
||||
onChange={onReasonChange}
|
||||
value={reason}
|
||||
disabled={busy}
|
||||
/>
|
||||
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
|
||||
</Field>
|
||||
{busy ? <InlineSpinner /> : null}
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|send_report")}
|
||||
onPrimaryButtonClick={onSubmit}
|
||||
focus={true}
|
||||
onCancel={onCancel}
|
||||
<Root id="mx_ReportEventDialog" onSubmit={onSubmit}>
|
||||
<Field name="reason">
|
||||
<Label htmlFor="mx_ReportRoomDialog_reason">{_t("report_room|reason_label")}</Label>
|
||||
<textarea
|
||||
id="mx_ReportRoomDialog_reason"
|
||||
rows={5}
|
||||
onChange={onReasonChange}
|
||||
value={reason}
|
||||
disabled={busy}
|
||||
/>
|
||||
</Root>
|
||||
)}
|
||||
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
|
||||
<HelpMessage>{_t("report_room|description")}</HelpMessage>
|
||||
</Field>
|
||||
{adminMessage}
|
||||
{busy ? <InlineSpinner /> : null}
|
||||
<LabelledToggleSwitch
|
||||
label={_t("room_list|more_options|leave_room")}
|
||||
value={leaveRoom}
|
||||
onChange={setLeaveRoom}
|
||||
/>
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|send_report")}
|
||||
onPrimaryButtonClick={onSubmit}
|
||||
focus={true}
|
||||
onCancel={onCancel}
|
||||
primaryButtonClass="danger"
|
||||
primaryDisabled={busy || !reason}
|
||||
/>
|
||||
</Root>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,17 +45,16 @@ import { type NonEmptyArray } from "../../../@types/common";
|
||||
import { SDKContext, type SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
|
||||
import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserSettingsTab";
|
||||
import { EncryptionUserSettingsTab, type State } from "../settings/tabs/user/EncryptionUserSettingsTab";
|
||||
|
||||
interface IProps {
|
||||
initialTabId?: UserTab;
|
||||
showMsc4108QrCode?: boolean;
|
||||
/**
|
||||
* If `true`, the flow for a user to reset their encryption will be shown. In this case, `initialTabId` must be `UserTab.Encryption`.
|
||||
*
|
||||
* If false or undefined, show the tab as normal.
|
||||
/*
|
||||
* The initial state of the Encryption tab.
|
||||
* If undefined, the default state is used ("loading").
|
||||
*/
|
||||
showResetIdentity?: boolean;
|
||||
initialEncryptionState?: State;
|
||||
sdkContext: SdkContextClass;
|
||||
onFinished(): void;
|
||||
}
|
||||
@@ -99,7 +98,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
const mjolnirEnabled = useSettingValue("feature_mjolnir");
|
||||
// store these props in state as changing tabs back and forth should clear them
|
||||
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
|
||||
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);
|
||||
const [initialEncryptionState, setInitialEncryptionState] = useState(props.initialEncryptionState);
|
||||
|
||||
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
|
||||
const tabs: Tab<UserTab>[] = [];
|
||||
@@ -195,7 +194,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
UserTab.Encryption,
|
||||
_td("settings|encryption|title"),
|
||||
<KeyIcon />,
|
||||
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
|
||||
<EncryptionUserSettingsTab initialState={initialEncryptionState} />,
|
||||
"UserSettingsEncryption",
|
||||
),
|
||||
);
|
||||
@@ -234,7 +233,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
||||
_setActiveTabId(tabId);
|
||||
// Clear these so switching away from the tab and back to it will not show the QR code again
|
||||
setShowMsc4108QrCode(false);
|
||||
setShowResetIdentity(false);
|
||||
setInitialEncryptionState(undefined);
|
||||
};
|
||||
|
||||
const [activeToast, toastRack] = useActiveToast();
|
||||
|
||||
@@ -86,11 +86,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
||||
};
|
||||
|
||||
private validateRecoveryKeyOnChange = debounce(async (): Promise<void> => {
|
||||
await this.validateRecoveryKey();
|
||||
await this.validateRecoveryKey(this.state.recoveryKey);
|
||||
}, VALIDATION_THROTTLE_MS);
|
||||
|
||||
private async validateRecoveryKey(): Promise<void> {
|
||||
if (this.state.recoveryKey === "") {
|
||||
private async validateRecoveryKey(recoveryKey: string): Promise<void> {
|
||||
if (recoveryKey === "") {
|
||||
this.setState({
|
||||
recoveryKeyValid: null,
|
||||
recoveryKeyCorrect: null,
|
||||
@@ -100,7 +100,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
||||
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const decodedKey = decodeRecoveryKey(this.state.recoveryKey);
|
||||
const decodedKey = decodeRecoveryKey(recoveryKey);
|
||||
const correct = await cli.secretStorage.checkKey(decodedKey, this.props.keyInfo);
|
||||
this.setState({
|
||||
recoveryKeyValid: true,
|
||||
@@ -148,11 +148,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
||||
// right number of characters, but it's really just to make sure that what we're reading is
|
||||
// text because we'll put it in the text field.
|
||||
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
|
||||
const recoveryKey = contents.trim();
|
||||
this.setState({
|
||||
recoveryKeyFileError: null,
|
||||
recoveryKey: contents.trim(),
|
||||
recoveryKey,
|
||||
});
|
||||
await this.validateRecoveryKey();
|
||||
await this.validateRecoveryKey(recoveryKey);
|
||||
} else {
|
||||
this.setState({
|
||||
recoveryKeyFileError: true,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 { _t } from "../../../../languageHandler";
|
||||
import BaseDialog from "../BaseDialog";
|
||||
import DialogButtons from "../../elements/DialogButtons";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success?: boolean) => void;
|
||||
}
|
||||
|
||||
export default class ConfirmDestroyCrossSigningDialog extends React.Component<IProps> {
|
||||
private onConfirm = (): void => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
private onDecline = (): void => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_ConfirmDestroyCrossSigningDialog"
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("encryption|destroy_cross_signing_dialog|title")}
|
||||
>
|
||||
<div className="mx_ConfirmDestroyCrossSigningDialog_content">
|
||||
<p>{_t("encryption|destroy_cross_signing_dialog|warning")}</p>
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("encryption|destroy_cross_signing_dialog|primary_button_text")}
|
||||
onPrimaryButtonClick={this.onConfirm}
|
||||
primaryButtonClass="danger"
|
||||
cancelButton={_t("action|cancel")}
|
||||
onCancel={this.onDecline}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,12 +135,19 @@ const AccessibleButton = forwardRef(function <T extends ElementType = typeof def
|
||||
placement = "right",
|
||||
onTooltipOpenChange,
|
||||
disableTooltip,
|
||||
role = "button",
|
||||
tabIndex = 0,
|
||||
...restProps
|
||||
}: ButtonProps<T>,
|
||||
ref: Ref<HTMLElementTagNameMap[T]>,
|
||||
): JSX.Element {
|
||||
const newProps = restProps as RenderedElementProps<T>;
|
||||
newProps["aria-label"] = newProps["aria-label"] ?? title;
|
||||
const newProps = {
|
||||
...restProps,
|
||||
tabIndex,
|
||||
role,
|
||||
"aria-label": restProps["aria-label"] ?? title,
|
||||
} as RenderedElementProps<T>;
|
||||
|
||||
if (disabled) {
|
||||
newProps["aria-disabled"] = true;
|
||||
newProps["disabled"] = true;
|
||||
@@ -222,10 +229,6 @@ const AccessibleButton = forwardRef(function <T extends ElementType = typeof def
|
||||
});
|
||||
|
||||
// Type assertion required due to forwardRef type workaround in react.d.ts
|
||||
(AccessibleButton as FunctionComponent).defaultProps = {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
};
|
||||
(AccessibleButton as FunctionComponent).displayName = "AccessibleButton";
|
||||
|
||||
interface RefProp<T extends ElementType> {
|
||||
|
||||
@@ -6,9 +6,8 @@ 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 from "react";
|
||||
import React, { type FC, useId } from "react";
|
||||
import classNames from "classnames";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { Caption } from "../typography/Caption";
|
||||
@@ -35,41 +34,50 @@ interface IProps {
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
|
||||
private readonly id = `mx_LabelledToggleSwitch_${secureRandomString(12)}`;
|
||||
const LabelledToggleSwitch: FC<IProps> = ({
|
||||
label,
|
||||
caption,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
tooltip,
|
||||
toggleInFront,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}) => {
|
||||
// This is a minimal version of a SettingsFlag
|
||||
const generatedId = useId();
|
||||
const id = `mx_LabelledToggleSwitch_${generatedId}`;
|
||||
let firstPart = (
|
||||
<span className="mx_SettingsFlag_label">
|
||||
<div id={id}>{label}</div>
|
||||
{caption && <Caption id={`${id}_caption`}>{caption}</Caption>}
|
||||
</span>
|
||||
);
|
||||
let secondPart = (
|
||||
<ToggleSwitch
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
tooltip={tooltip}
|
||||
aria-labelledby={id}
|
||||
aria-describedby={caption ? `${id}_caption` : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
public render(): React.ReactNode {
|
||||
// This is a minimal version of a SettingsFlag
|
||||
const { label, caption } = this.props;
|
||||
let firstPart = (
|
||||
<span className="mx_SettingsFlag_label">
|
||||
<div id={this.id}>{label}</div>
|
||||
{caption && <Caption id={`${this.id}_caption`}>{caption}</Caption>}
|
||||
</span>
|
||||
);
|
||||
let secondPart = (
|
||||
<ToggleSwitch
|
||||
checked={this.props.value}
|
||||
disabled={this.props.disabled}
|
||||
onChange={this.props.onChange}
|
||||
tooltip={this.props.tooltip}
|
||||
aria-labelledby={this.id}
|
||||
aria-describedby={caption ? `${this.id}_caption` : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.toggleInFront) {
|
||||
[firstPart, secondPart] = [secondPart, firstPart];
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SettingsFlag", this.props.className, {
|
||||
mx_SettingsFlag_toggleInFront: this.props.toggleInFront,
|
||||
});
|
||||
return (
|
||||
<div data-testid={this.props["data-testid"]} className={classes}>
|
||||
{firstPart}
|
||||
{secondPart}
|
||||
</div>
|
||||
);
|
||||
if (toggleInFront) {
|
||||
[firstPart, secondPart] = [secondPart, firstPart];
|
||||
}
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SettingsFlag", className, {
|
||||
mx_SettingsFlag_toggleInFront: toggleInFront,
|
||||
});
|
||||
return (
|
||||
<div data-testid={testId} className={classes}>
|
||||
{firstPart}
|
||||
{secondPart}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelledToggleSwitch;
|
||||
|
||||
@@ -233,10 +233,16 @@ const RoomSummaryCard: React.FC<IProps> = ({
|
||||
room_id: room.roomId,
|
||||
});
|
||||
};
|
||||
const onReportRoomClick = (): void => {
|
||||
Modal.createDialog(ReportRoomDialog, {
|
||||
const onReportRoomClick = async (): Promise<void> => {
|
||||
const [leave] = await Modal.createDialog(ReportRoomDialog, {
|
||||
roomId: room.roomId,
|
||||
});
|
||||
}).finished;
|
||||
if (leave) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isRoomEncrypted = useIsEncrypted(cli, room);
|
||||
@@ -447,6 +453,12 @@ const RoomSummaryCard: React.FC<IProps> = ({
|
||||
|
||||
<Separator />
|
||||
<div className="mx_RoomSummaryCard_bottomOptions">
|
||||
<MenuItem
|
||||
Icon={ErrorIcon}
|
||||
kind="critical"
|
||||
label={_t("action|report_room")}
|
||||
onSelect={onReportRoomClick}
|
||||
/>
|
||||
<MenuItem
|
||||
className="mx_RoomSummaryCard_leave"
|
||||
Icon={LeaveIcon}
|
||||
@@ -454,12 +466,6 @@ const RoomSummaryCard: React.FC<IProps> = ({
|
||||
label={_t("action|leave_room")}
|
||||
onSelect={onLeaveRoomClick}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={ErrorIcon}
|
||||
kind="critical"
|
||||
label={_t("action|report_room")}
|
||||
onSelect={onReportRoomClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -6,7 +6,14 @@ 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, type ChangeEvent, type ContextType, createRef, type SyntheticEvent } from "react";
|
||||
import React, {
|
||||
type JSX,
|
||||
type ToggleEvent,
|
||||
type ChangeEvent,
|
||||
type ContextType,
|
||||
createRef,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import { type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type RoomCanonicalAliasEventContent } from "matrix-js-sdk/src/types";
|
||||
@@ -278,7 +285,7 @@ export default class AliasSettings extends React.Component<IProps, IState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onLocalAliasesToggled = (event: ChangeEvent<HTMLDetailsElement>): void => {
|
||||
private onLocalAliasesToggled = (event: ToggleEvent<HTMLDetailsElement>): void => {
|
||||
// expanded
|
||||
if (event.currentTarget.open) {
|
||||
// if local aliases haven't been preloaded yet at component mount
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import React, { type HTMLProps, type JSX } from "react";
|
||||
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
@@ -19,6 +20,10 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
* The notification state of the room or thread.
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* Whether the room has a video call.
|
||||
*/
|
||||
hasVideoCall: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +31,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
*/
|
||||
export function NotificationDecoration({
|
||||
notificationState,
|
||||
hasVideoCall,
|
||||
...props
|
||||
}: NotificationDecorationProps): JSX.Element | null {
|
||||
const {
|
||||
@@ -38,7 +44,7 @@ export function NotificationDecoration({
|
||||
count,
|
||||
muted,
|
||||
} = notificationState;
|
||||
if (!hasAnyNotificationOrActivity && !muted) return null;
|
||||
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -49,6 +55,7 @@ export function NotificationDecoration({
|
||||
data-testid="notification-decoration"
|
||||
>
|
||||
{isUnsetMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
|
||||
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{invited && <UnreadCounter count={1} />}
|
||||
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Ele
|
||||
rowHeight={48}
|
||||
height={height}
|
||||
width={width}
|
||||
scrollToIndex={activeIndex}
|
||||
scrollToIndex={activeIndex ?? 0}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function RoomListHeaderView(): JSX.Element {
|
||||
<ComposeMenu vm={vm} />
|
||||
) : (
|
||||
<IconButton aria-label={_t("action|new_message")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
|
||||
<ComposeIcon />
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -76,20 +76,36 @@ function SpaceMenu({ vm }: SpaceMenuProps): JSX.Element {
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton className="mx_SpaceMenu_button" aria-label={_t("room_list|open_space_menu")} size="20px">
|
||||
<ChevronDownIcon />
|
||||
<ChevronDownIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={HomeIcon} label={_t("room_list|space_menu|home")} onSelect={vm.openSpaceHome} />
|
||||
<MenuItem
|
||||
Icon={HomeIcon}
|
||||
label={_t("room_list|space_menu|home")}
|
||||
onSelect={vm.openSpaceHome}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canInviteInSpace && (
|
||||
<MenuItem Icon={UserAddIcon} label={_t("action|invite")} onSelect={vm.inviteInSpace} />
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
onSelect={vm.inviteInSpace}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<MenuItem Icon={PreferencesIcon} label={_t("common|preferences")} onSelect={vm.openSpacePreferences} />
|
||||
<MenuItem
|
||||
Icon={PreferencesIcon}
|
||||
label={_t("common|preferences")}
|
||||
onSelect={vm.openSpacePreferences}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canAccessSpaceSettings && (
|
||||
<MenuItem
|
||||
Icon={SettingsIcon}
|
||||
label={_t("room_list|space_menu|space_settings")}
|
||||
onSelect={vm.openSpaceSettings}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
@@ -119,14 +135,26 @@ function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton aria-label={_t("action|add")}>
|
||||
<ComposeIcon />
|
||||
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={UserAddIcon} label={_t("action|new_message")} onSelect={vm.createChatRoom} />
|
||||
{vm.canCreateRoom && <MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} />}
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|new_message")}
|
||||
onSelect={vm.createChatRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canCreateRoom && (
|
||||
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
|
||||
)}
|
||||
{vm.canCreateVideoRoom && (
|
||||
<MenuItem Icon={VideoCallIcon} label={_t("action|new_video_room")} onSelect={vm.createVideoRoom} />
|
||||
<MenuItem
|
||||
Icon={VideoCallIcon}
|
||||
label={_t("action|new_video_room")}
|
||||
onSelect={vm.createVideoRoom}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,9 @@ import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user
|
||||
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
|
||||
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
|
||||
import OverflowIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||
import NotificationIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-solid";
|
||||
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
|
||||
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
type RoomListItemMenuViewState,
|
||||
useRoomListItemMenuViewModel,
|
||||
} from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
|
||||
import { RoomNotifState } from "../../../../RoomNotifs";
|
||||
|
||||
interface RoomListItemMenuViewProps {
|
||||
/**
|
||||
@@ -45,6 +49,7 @@ export function RoomListItemMenuView({ room, setMenuOpen }: RoomListItemMenuView
|
||||
return (
|
||||
<Flex className="mx_RoomListItemMenuView" align="center" gap="var(--cpd-space-0-5x)">
|
||||
{vm.showMoreOptionsMenu && <MoreOptionsMenu setMenuOpen={setMenuOpen} vm={vm} />}
|
||||
{vm.showNotificationMenu && <NotificationMenu setMenuOpen={setMenuOpen} vm={vm} />}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +90,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("room_list|more_options|mark_read")}
|
||||
onSelect={vm.markAsRead}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{vm.canMarkAsUnread && (
|
||||
@@ -93,6 +99,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("room_list|more_options|mark_unread")}
|
||||
onSelect={vm.markAsUnread}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<ToggleMenuItem
|
||||
@@ -107,6 +114,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("room_list|more_options|low_priority")}
|
||||
onSelect={vm.toggleLowPriority}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
{vm.canInvite && (
|
||||
<MenuItem
|
||||
@@ -114,6 +122,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("action|invite")}
|
||||
onSelect={vm.invite}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
{vm.canCopyRoomLink && (
|
||||
@@ -122,6 +131,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("room_list|more_options|copy_link")}
|
||||
onSelect={vm.copyRoomLink}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
)}
|
||||
<Separator />
|
||||
@@ -131,6 +141,7 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
|
||||
label={_t("room_list|more_options|leave_room")}
|
||||
onSelect={vm.leaveRoom}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
hideChevron={true}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
@@ -152,3 +163,95 @@ export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButton
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface NotificationMenuProps {
|
||||
/**
|
||||
* The view model state for the menu.
|
||||
*/
|
||||
vm: RoomListItemMenuViewState;
|
||||
/**
|
||||
* Set the menu open state.
|
||||
* @param isOpen
|
||||
*/
|
||||
setMenuOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function NotificationMenu({ vm, setMenuOpen }: NotificationMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const checkComponent = <CheckIcon width="24px" height="24px" color="var(--cpd-color-icon-primary)" />;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
setMenuOpen(isOpen);
|
||||
}}
|
||||
title={_t("room_list|notification_options")}
|
||||
showTitle={false}
|
||||
align="start"
|
||||
trigger={<NotificationButton isRoomMuted={vm.isNotificationMute} size="24px" />}
|
||||
>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationAllMessage}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|default_settings")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessages)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationAllMessage && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationAllMessageLoud}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|all_messages")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.AllMessagesLoud)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationAllMessageLoud && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationMentionOnly}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mentions_keywords")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.MentionsOnly)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationMentionOnly && checkComponent}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
aria-selected={vm.isNotificationMute}
|
||||
hideChevron={true}
|
||||
label={_t("notifications|mute_room")}
|
||||
onSelect={() => vm.setRoomNotifState(RoomNotifState.Mute)}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
{vm.isNotificationMute && checkComponent}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
|
||||
/**
|
||||
* Whether the room is muted.
|
||||
*/
|
||||
isRoomMuted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button to trigger the notification menu.
|
||||
*/
|
||||
export const NotificationButton = forwardRef<HTMLButtonElement, NotificationButtonProps>(function MoreOptionsButton(
|
||||
{ isRoomMuted, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<Tooltip label={_t("room_list|notification_options")}>
|
||||
<IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}>
|
||||
{isRoomMuted ? <NotificationOffIcon /> : <NotificationIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,10 +10,10 @@ import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
|
||||
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
|
||||
import { Flex } from "../../../utils/Flex";
|
||||
import { RoomListItemMenuView } from "./RoomListItemMenuView";
|
||||
import { NotificationDecoration } from "../NotificationDecoration";
|
||||
import { RoomAvatarView } from "../../avatars/RoomAvatarView";
|
||||
|
||||
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
@@ -39,7 +39,8 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
|
||||
const showHoverDecoration = (isMenuOpen || isHover) && vm.showHoverMenu;
|
||||
|
||||
const isNotificationDecorationVisible =
|
||||
!showHoverDecoration && (vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted);
|
||||
!showHoverDecoration &&
|
||||
(vm.notificationState.hasAnyNotificationOrActivity || vm.notificationState.muted || vm.hasParticipantInCall);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -48,6 +49,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
|
||||
mx_RoomListItemView_notification_decoration: isNotificationDecorationVisible,
|
||||
mx_RoomListItemView_menu_open: showHoverDecoration,
|
||||
mx_RoomListItemView_selected: isSelected,
|
||||
mx_RoomListItemView_bold: vm.isBold,
|
||||
})}
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
@@ -61,7 +63,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
|
||||
>
|
||||
{/* We need this extra div between the button and the content in order to add a padding which is not messing with the virtualized list */}
|
||||
<Flex className="mx_RoomListItemView_container" gap="var(--cpd-space-3x)" align="center">
|
||||
<DecoratedRoomAvatar room={room} size="32px" />
|
||||
<RoomAvatarView room={room} />
|
||||
<Flex
|
||||
className="mx_RoomListItemView_content"
|
||||
gap="var(--cpd-space-3x)"
|
||||
@@ -69,7 +71,9 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
|
||||
justify="space-between"
|
||||
>
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<span title={room.name}>{room.name}</span>
|
||||
<span className="mx_RoomListItemView_roomName" title={room.name}>
|
||||
{room.name}
|
||||
</span>
|
||||
{showHoverDecoration ? (
|
||||
<RoomListItemMenuView
|
||||
room={room}
|
||||
@@ -82,7 +86,11 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
|
||||
) : (
|
||||
<>
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
|
||||
<NotificationDecoration notificationState={vm.notificationState} aria-hidden={true} />
|
||||
<NotificationDecoration
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
hasVideoCall={vm.hasParticipantInCall}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewM
|
||||
import { RoomList } from "./RoomList";
|
||||
import { EmptyRoomList } from "./EmptyRoomList";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
import Spinner from "../../elements/Spinner";
|
||||
|
||||
/**
|
||||
* Host the room list and the (future) room filters
|
||||
@@ -18,11 +19,18 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
export function RoomListView(): JSX.Element {
|
||||
const vm = useRoomListViewModel();
|
||||
const isRoomListEmpty = vm.rooms.length === 0;
|
||||
|
||||
let listBody;
|
||||
if (vm.isLoadingRooms) {
|
||||
listBody = <Spinner />;
|
||||
} else if (isRoomListEmpty) {
|
||||
listBody = <EmptyRoomList vm={vm} />;
|
||||
} else {
|
||||
listBody = <RoomList vm={vm} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RoomListPrimaryFilters vm={vm} />
|
||||
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
|
||||
{listBody}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type RoomPreviewOpts,
|
||||
RoomViewLifecycle,
|
||||
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
@@ -90,12 +91,18 @@ interface IProps {
|
||||
roomAlias?: string;
|
||||
|
||||
onJoinClick?(): void;
|
||||
onRejectClick?(): void;
|
||||
onRejectAndIgnoreClick?(): void;
|
||||
onDeclineClick?(): void;
|
||||
onDeclineAndBlockClick?(): void;
|
||||
onForgetClick?(): void;
|
||||
|
||||
canAskToJoinAndMembershipIsLeave?: boolean;
|
||||
promptAskToJoin?: boolean;
|
||||
|
||||
/**
|
||||
* If true, this will prompt for additional safety options
|
||||
* like reporting an invite or ignoring the user.
|
||||
*/
|
||||
promptRejectionOptions?: boolean;
|
||||
knocked?: boolean;
|
||||
onSubmitAskToJoin?(reason?: string): void;
|
||||
onCancelAskToJoin?(): void;
|
||||
@@ -313,6 +320,8 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
let primaryActionLabel: string | undefined;
|
||||
let secondaryActionHandler: (() => void) | undefined;
|
||||
let secondaryActionLabel: string | undefined;
|
||||
let dangerActionHandler: (() => void) | undefined;
|
||||
let dangerActionLabel: string | undefined;
|
||||
let footer: JSX.Element | undefined;
|
||||
const extraComponents: JSX.Element[] = [];
|
||||
|
||||
@@ -549,16 +558,11 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
secondaryActionLabel = _t("action|reject");
|
||||
secondaryActionHandler = this.props.onRejectClick;
|
||||
secondaryActionLabel = _t("action|decline");
|
||||
secondaryActionHandler = this.props.onDeclineClick;
|
||||
dangerActionLabel = _t("action|decline_and_block");
|
||||
dangerActionHandler = this.props.onDeclineAndBlockClick;
|
||||
|
||||
if (this.props.onRejectAndIgnoreClick) {
|
||||
extraComponents.push(
|
||||
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
|
||||
{_t("room|invite_reject_ignore")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageCase.ViewingRoom: {
|
||||
@@ -691,6 +695,15 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
let dangerActionButton;
|
||||
if (dangerActionHandler) {
|
||||
dangerActionButton = (
|
||||
<Button destructive kind="tertiary" onClick={dangerActionHandler}>
|
||||
{dangerActionLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const isPanel = this.props.canPreview;
|
||||
|
||||
const classes = classNames("mx_RoomPreviewBar", `mx_RoomPreviewBar_${messageCase}`, {
|
||||
@@ -701,6 +714,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
// ensure correct tab order for both views
|
||||
const actions = isPanel ? (
|
||||
<>
|
||||
{dangerActionButton}
|
||||
{secondaryButton}
|
||||
{extraComponents}
|
||||
{primaryButton}
|
||||
@@ -710,6 +724,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
{primaryButton}
|
||||
{extraComponents}
|
||||
{secondaryButton}
|
||||
{dangerActionButton}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||
onRejectButtonClicked();
|
||||
}}
|
||||
>
|
||||
{_t("action|reject")}
|
||||
{_t("action|decline")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
|
||||
@@ -40,6 +40,8 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
|
||||
{ count: searchInfo.count },
|
||||
{ query: () => <strong>{searchInfo.term}</strong> },
|
||||
)
|
||||
) : searchInfo?.error !== undefined ? (
|
||||
searchInfo?.error.message
|
||||
) : (
|
||||
<InlineSpinner />
|
||||
)}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { ClientEvent, type EmptyObject, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
|
||||
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
|
||||
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
|
||||
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
|
||||
interface IState {
|
||||
error: boolean;
|
||||
crossSigningPublicKeysOnDevice?: boolean;
|
||||
crossSigningPrivateKeysInStorage?: boolean;
|
||||
masterPrivateKeyCached?: boolean;
|
||||
selfSigningPrivateKeyCached?: boolean;
|
||||
userSigningPrivateKeyCached?: boolean;
|
||||
homeserverSupportsCrossSigning?: boolean;
|
||||
crossSigningReady?: boolean;
|
||||
}
|
||||
|
||||
export default class CrossSigningPanel extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
cli.on(ClientEvent.AccountData, this.onAccountData);
|
||||
cli.on(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
|
||||
cli.on(CryptoEvent.KeysChanged, this.onStatusChanged);
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) return;
|
||||
cli.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
|
||||
cli.removeListener(CryptoEvent.KeysChanged, this.onStatusChanged);
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent): void => {
|
||||
const type = event.getType();
|
||||
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
};
|
||||
|
||||
private onBootstrapClick = (): void => {
|
||||
if (this.state.crossSigningPrivateKeysInStorage) {
|
||||
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
|
||||
} else {
|
||||
// Trigger the flow to set up secure backup, which is what this will do when in
|
||||
// the appropriate state.
|
||||
accessSecretStorage();
|
||||
}
|
||||
};
|
||||
|
||||
private onStatusChanged = (): void => {
|
||||
this.getUpdatedStatus();
|
||||
};
|
||||
|
||||
private async getUpdatedStatus(): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const crossSigningPublicKeysOnDevice = crossSigningStatus.publicKeysOnDevice;
|
||||
const crossSigningPrivateKeysInStorage = crossSigningStatus.privateKeysInSecretStorage;
|
||||
const masterPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.masterKey;
|
||||
const selfSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.selfSigningKey;
|
||||
const userSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
const homeserverSupportsCrossSigning =
|
||||
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
|
||||
this.setState({
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
homeserverSupportsCrossSigning,
|
||||
crossSigningReady,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the user's cross-signing keys.
|
||||
*/
|
||||
private async resetCrossSigning(): Promise<void> {
|
||||
this.setState({ error: false });
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
await withSecretStorageKeyCache(async () => {
|
||||
await cli.getCrypto()!.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||
title: _t("encryption|bootstrap_title"),
|
||||
matrixClient: cli,
|
||||
makeRequest,
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({ error: true });
|
||||
logger.error("Error bootstrapping cross-signing", e);
|
||||
}
|
||||
if (this.unmounted) return;
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when the user clicks the "reset cross signing" button.
|
||||
*
|
||||
* Shows a confirmation dialog, and then does the reset if confirmed.
|
||||
*/
|
||||
private onResetCrossSigningClick = (): void => {
|
||||
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
|
||||
onFinished: async (act) => {
|
||||
if (!act) return;
|
||||
this.resetCrossSigning();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const {
|
||||
error,
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
homeserverSupportsCrossSigning,
|
||||
crossSigningReady,
|
||||
} = this.state;
|
||||
|
||||
let errorSection;
|
||||
if (error) {
|
||||
errorSection = <div className="error">{error.toString()}</div>;
|
||||
}
|
||||
|
||||
let summarisedStatus;
|
||||
if (homeserverSupportsCrossSigning === undefined) {
|
||||
summarisedStatus = <Spinner />;
|
||||
} else if (!homeserverSupportsCrossSigning) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_unsupported")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningReady && crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
✅ {_t("encryption|cross_signing_ready")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningReady && !crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
⚠️ {_t("encryption|cross_signing_ready_no_backup")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_untrusted")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_not_ready")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
}
|
||||
|
||||
const keysExistAnywhere =
|
||||
crossSigningPublicKeysOnDevice ||
|
||||
crossSigningPrivateKeysInStorage ||
|
||||
masterPrivateKeyCached ||
|
||||
selfSigningPrivateKeyCached ||
|
||||
userSigningPrivateKeyCached;
|
||||
const keysExistEverywhere =
|
||||
crossSigningPublicKeysOnDevice &&
|
||||
crossSigningPrivateKeysInStorage &&
|
||||
masterPrivateKeyCached &&
|
||||
selfSigningPrivateKeyCached &&
|
||||
userSigningPrivateKeyCached;
|
||||
|
||||
const actions: JSX.Element[] = [];
|
||||
|
||||
// TODO: determine how better to expose this to users in addition to prompts at login/toast
|
||||
if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
|
||||
let buttonCaption = _t("encryption|set_up_toast_title");
|
||||
if (crossSigningPrivateKeysInStorage) {
|
||||
buttonCaption = _t("encryption|verify_toast_title");
|
||||
}
|
||||
actions.push(
|
||||
<AccessibleButton key="setup" kind="primary_outline" onClick={this.onBootstrapClick}>
|
||||
{buttonCaption}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
if (keysExistAnywhere) {
|
||||
actions.push(
|
||||
<AccessibleButton key="reset" kind="danger_outline" onClick={this.onResetCrossSigningClick}>
|
||||
{_t("action|reset")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
let actionRow;
|
||||
if (actions.length) {
|
||||
actionRow = <div className="mx_CrossSigningPanel_buttonRow">{actions}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{summarisedStatus}
|
||||
<details>
|
||||
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
|
||||
<table className="mx_CrossSigningPanel_statusList">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
|
||||
<td>
|
||||
{crossSigningPublicKeysOnDevice
|
||||
? _t("settings|security|cross_signing_in_memory")
|
||||
: _t("settings|security|cross_signing_not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
|
||||
<td>
|
||||
{crossSigningPrivateKeysInStorage
|
||||
? _t("settings|security|cross_signing_in_4s")
|
||||
: _t("settings|security|cross_signing_not_in_4s")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
|
||||
<td>
|
||||
{masterPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
|
||||
<td>
|
||||
{selfSigningPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
|
||||
<td>
|
||||
{userSigningPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
|
||||
<td>
|
||||
{homeserverSupportsCrossSigning
|
||||
? _t("settings|security|cross_signing_homeserver_support_exists")
|
||||
: _t("settings|security|cross_signing_not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{errorSection}
|
||||
{actionRow}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||