diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c531f89b4..35803a60f1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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@66088c44e212a906c32a047529a213d81809ec1c + uses: guibranco/github-status-action-v2@d469d49426f5a7b8a1fbcac20ad274d3e4892321 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success diff --git a/package.json b/package.json index 5976379ab2..9cd0163945 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^8.0.0", "@vector-im/compound-design-tokens": "^2.0.1", - "@vector-im/compound-web": "^7.4.0", + "@vector-im/compound-web": "^7.5.0", "@vector-im/matrix-wysiwyg": "2.37.13", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", @@ -269,7 +269,7 @@ "postcss-preset-env": "^10.0.0", "postcss-scss": "^4.0.4", "postcss-simple-vars": "^7.0.1", - "prettier": "3.4.1", + "prettier": "3.4.2", "process": "^0.11.10", "raw-loader": "^4.0.2", "rimraf": "^6.0.0", diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts index 4c8d641e6f..bd3d859526 100644 --- a/playwright/e2e/crypto/user-verification.spec.ts +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; +import type { Page } from "@playwright/test"; import { test, expect } from "../../element-web-test"; import { doTwoWaySasVerification, awaitVerifier } from "./utils"; import { Client } from "../../pages/client"; @@ -38,6 +39,8 @@ test.describe("User verification", () => { toasts, room: { roomId: dmRoomId }, }) => { + await waitForDeviceKeys(page); + // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -87,6 +90,8 @@ test.describe("User verification", () => { toasts, room: { roomId: dmRoomId }, }) => { + await waitForDeviceKeys(page); + // once Alice has joined, Bob starts the verification const bobVerificationRequest = await bob.evaluateHandle( async (client, { dmRoomId, aliceCredentials }) => { @@ -149,3 +154,15 @@ async function createDMRoom(client: Client, userId: string): Promise { ], }); } + +/** + * Wait until we get the other user's device keys. + * In newer rust-crypto versions, the verification request will be ignored if we + * don't have the sender's device keys. + */ +async function waitForDeviceKeys(page: Page): Promise { + await expect(page.getByRole("button", { name: "Avatar" })).toBeVisible(); + const avatar = await page.getByRole("button", { name: "Avatar" }); + await avatar.click(); + await expect(page.getByText("1 session")).toBeVisible(); +} diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 1922058201..078ca2848f 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // Docker tag to use for synapse docker image. // We target a specific digest as every now and then a Synapse update will break our CI. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:6ff2b43b7412eb4155c0147441421b31fc4b31acd56be82cf27daf172ababa4d"; +const DOCKER_TAG = "develop@sha256:6b82dba715fa7ae641010b4cc5e71edaeb9cc05a50ac5b9e4ff09afa9cd2a80d"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index b2b71375bd..41ffca6c93 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png index 0d18bff1c2..147fcfa057 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png index 9cadcde415..5475f9a537 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png index 1ec17661fe..23b88c022c 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-removed-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png index 75db794a1a..6378098d7a 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png index 357790598d..f2269a0532 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png index 42f27d10bf..6b41f30acd 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png differ diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index be36c5b689..f921cd291f 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -44,6 +44,7 @@ import { IConfigOptions } from "../IConfigOptions"; import { MatrixDispatcher } from "../dispatcher/dispatcher"; import { DeepReadonly } from "./common"; import MatrixChat from "../components/structures/MatrixChat"; +import { InitialCryptoSetupStore } from "../stores/InitialCryptoSetupStore"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -117,6 +118,7 @@ declare global { mxPerformanceEntryNames: any; mxUIStore: UIStore; mxSetupEncryptionStore?: SetupEncryptionStore; + mxInitialCryptoStore?: InitialCryptoSetupStore; mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; mxOnRecaptchaLoaded?: () => void; diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx index d8c7d912c1..9d8b5585e3 100644 --- a/src/accessibility/context_menu/ContextMenuButton.tsx +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -8,25 +8,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, forwardRef, Ref } from "react"; +import React, { forwardRef, Ref } from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import AccessibleButton, { ButtonProps } from "../../components/views/elements/AccessibleButton"; -type Props = ComponentProps> & { +type Props = ButtonProps & { label?: string; // whether the context menu is currently open isExpanded: boolean; }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = forwardRef(function ( - { label, isExpanded, children, onClick, onContextMenu, element, ...props }: Props, - ref: Ref, +export const ContextMenuButton = forwardRef(function ( + { label, isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, ) { return ( = ComponentProps> & { +type Props = ButtonProps & { // whether the context menu is currently open isExpanded: boolean; }; // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuTooltipButton = forwardRef(function ( - { isExpanded, children, onClick, onContextMenu, element, ...props }: Props, - ref: Ref, +export const ContextMenuTooltipButton = forwardRef(function ( + { isExpanded, children, onClick, onContextMenu, ...props }: Props, + ref: Ref, ) { return ( = Omit< - ComponentProps>, - "inputRef" | "tabIndex" -> & { - inputRef?: Ref; +type Props = Omit, "tabIndex"> & { + inputRef?: RefObject; focusOnMouseOver?: boolean; }; // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton = ({ +export const RovingAccessibleButton = ({ inputRef, onFocus, onMouseOver, focusOnMouseOver, - element, ...props }: Props): JSX.Element => { - const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( { + onFocus={(event: React.FocusEvent) => { onFocusInternal(); onFocus?.(event); }} - onMouseOver={(event: React.MouseEvent) => { + onMouseOver={(event: React.MouseEvent) => { if (focusOnMouseOver) onFocusInternal(); onMouseOver?.(event); }} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 548dbff983..ee120c430a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -132,6 +132,7 @@ import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; +import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; // legacy export export { default as Views } from "../../Views"; @@ -428,6 +429,12 @@ export default class MatrixChat extends React.PureComponent { !(await shouldSkipSetupEncryption(cli)) ) { // if cross-signing is not yet set up, do so now if possible. + InitialCryptoSetupStore.sharedInstance().startInitialCryptoSetup( + cli, + Boolean(this.tokenLogin), + this.stores, + this.onCompleteSecurityE2eSetupFinished, + ); this.setStateForNewView({ view: Views.E2E_SETUP }); } else { this.onLoggedIn(); @@ -2073,14 +2080,7 @@ export default class MatrixChat extends React.PureComponent { } else if (this.state.view === Views.COMPLETE_SECURITY) { view = ; } else if (this.state.view === Views.E2E_SETUP) { - view = ( - - ); + view = ; } else if (this.state.view === Views.LOGGED_IN) { // `ready` and `view==LOGGED_IN` may be set before `page_type` (because the // latter is set via the dispatcher). If we don't yet have a `page_type`, diff --git a/src/components/structures/auth/E2eSetup.tsx b/src/components/structures/auth/E2eSetup.tsx index 265905db10..3b064d6134 100644 --- a/src/components/structures/auth/E2eSetup.tsx +++ b/src/components/structures/auth/E2eSetup.tsx @@ -7,17 +7,13 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; import AuthPage from "../../views/auth/AuthPage"; import CompleteSecurityBody from "../../views/auth/CompleteSecurityBody"; import { InitialCryptoSetupDialog } from "../../views/dialogs/security/InitialCryptoSetupDialog"; interface IProps { - matrixClient: MatrixClient; onFinished: () => void; - accountPassword?: string; - tokenLogin: boolean; } export default class E2eSetup extends React.Component { @@ -25,12 +21,7 @@ export default class E2eSetup extends React.Component { return ( - + ); diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index db72a0a04b..9c7a900643 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -235,12 +235,7 @@ export default class SoftLogout extends React.Component { value={this.state.password} disabled={this.state.busy} /> - + {_t("action|sign_in")} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index b1360f5560..ae5c07e348 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -910,7 +910,7 @@ export class SSOAuthEntry extends React.Component extends React.Component { protected popupWindow: Window | null; - protected fallbackButton = createRef(); + protected fallbackButton = createRef(); public constructor(props: IAuthEntryProps & T) { super(props); diff --git a/src/components/views/dialogs/devtools/SettingExplorer.tsx b/src/components/views/dialogs/devtools/SettingExplorer.tsx index ed4b64d870..ae37fa3e1c 100644 --- a/src/components/views/dialogs/devtools/SettingExplorer.tsx +++ b/src/components/views/dialogs/devtools/SettingExplorer.tsx @@ -298,7 +298,7 @@ const SettingsList: React.FC = ({ onBack, onView, onEdit }) {i} onEdit(i)} className="mx_DevTools_SettingsExplorer_edit" > diff --git a/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx index 4ee69f17a4..22635662ce 100644 --- a/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx +++ b/src/components/views/dialogs/security/InitialCryptoSetupDialog.tsx @@ -7,20 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { useCallback, useEffect, useState } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import React, { useCallback } from "react"; import { _t } from "../../../../languageHandler"; import DialogButtons from "../../elements/DialogButtons"; import BaseDialog from "../BaseDialog"; import Spinner from "../../elements/Spinner"; -import { createCrossSigning } from "../../../../CreateCrossSigning"; +import { InitialCryptoSetupStore, useInitialCryptoSetupStatus } from "../../../../stores/InitialCryptoSetupStore"; interface Props { - matrixClient: MatrixClient; - accountPassword?: string; - tokenLogin: boolean; onFinished: (success?: boolean) => void; } @@ -29,54 +24,27 @@ interface Props { * In most cases, only a spinner is shown, but for more * complex auth like SSO, the user may need to complete some steps to proceed. */ -export const InitialCryptoSetupDialog: React.FC = ({ - matrixClient, - accountPassword, - tokenLogin, - onFinished, -}) => { - const [error, setError] = useState(false); +export const InitialCryptoSetupDialog: React.FC = ({ onFinished }) => { + const onRetryClick = useCallback(() => { + InitialCryptoSetupStore.sharedInstance().retry(); + }, []); - const doSetup = useCallback(async () => { - const cryptoApi = matrixClient.getCrypto(); - if (!cryptoApi) return; - - setError(false); - - try { - await createCrossSigning(matrixClient, tokenLogin, accountPassword); - - onFinished(true); - } catch (e) { - if (tokenLogin) { - // ignore any failures, we are relying on grace period here - onFinished(false); - return; - } - - setError(true); - logger.error("Error bootstrapping cross-signing", e); - } - }, [matrixClient, tokenLogin, accountPassword, onFinished]); - - const onCancel = useCallback(() => { + const onCancelClick = useCallback(() => { onFinished(false); }, [onFinished]); - useEffect(() => { - doSetup(); - }, [doSetup]); + const status = useInitialCryptoSetupStatus(InitialCryptoSetupStore.sharedInstance()); let content; - if (error) { + if (status === "error") { content = (

{_t("encryption|unable_to_setup_keys_error")}

diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 20d6825b9b..a87b7341e7 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -1253,7 +1253,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n {filterToLabel(filter)} = ButtonProps & { +type TooltipOptionProps = ButtonProps & { + className?: string; endAdornment?: ReactNode; inputRef?: Ref; }; -export const TooltipOption = ({ +export const TooltipOption = ({ inputRef, className, - element, ...props }: TooltipOptionProps): JSX.Element => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); @@ -34,7 +34,6 @@ export const TooltipOption = ({ tabIndex={-1} aria-selected={isActive} role="option" - element={element as keyof JSX.IntrinsicElements} /> ); }; diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index 43d123676b..a1b1986f47 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -168,7 +168,7 @@ export const NetworkDropdown: React.FC = ({ protocols, config, setConfig adornment: ( setUserDefinedServers(without(userDefinedServers, roomServer))} /> ), diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index b8b5297384..8b58f251c3 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -6,7 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, forwardRef, FunctionComponent, HTMLAttributes, InputHTMLAttributes, Ref } from "react"; +import React, { + ComponentProps, + ComponentPropsWithoutRef, + forwardRef, + FunctionComponent, + ReactElement, + KeyboardEvent, + Ref, +} from "react"; import classnames from "classnames"; import { Tooltip } from "@vector-im/compound-web"; @@ -38,20 +46,8 @@ export type AccessibleButtonKind = | "icon_primary" | "icon_primary_outline"; -/** - * This type construct allows us to specifically pass those props down to the element we’re creating that the element - * actually supports. - * - * e.g., if element is set to "a", we’ll support href and target, if it’s set to "input", we support type. - * - * To remain compatible with existing code, we’ll continue to support InputHTMLAttributes - */ -type DynamicHtmlElementProps = - JSX.IntrinsicElements[T] extends HTMLAttributes<{}> ? DynamicElementProps : DynamicElementProps<"div">; -type DynamicElementProps = Partial< - Omit -> & - Omit, "onClick">; +type ElementType = keyof HTMLElementTagNameMap; +const defaultElement = "div"; type TooltipProps = ComponentProps; @@ -60,7 +56,7 @@ type TooltipProps = ComponentProps; * * Extends props accepted by the underlying element specified using the `element` prop. */ -type Props = DynamicHtmlElementProps & { +type Props = { /** * The base element type. "div" by default. */ @@ -105,14 +101,12 @@ type Props = DynamicHtmlElementProps & disableTooltip?: TooltipProps["disabled"]; }; -export type ButtonProps = Props; +export type ButtonProps = Props & Omit, keyof Props>; /** * Type of the props passed to the element that is rendered by AccessibleButton. */ -interface RenderedElementProps extends React.InputHTMLAttributes { - ref?: React.Ref; -} +type RenderedElementProps = React.InputHTMLAttributes & RefProp; /** * AccessibleButton is a generic wrapper for any element that should be treated @@ -124,9 +118,9 @@ interface RenderedElementProps extends React.InputHTMLAttributes { * @param {Object} props react element properties * @returns {Object} rendered react */ -const AccessibleButton = forwardRef(function ( +const AccessibleButton = forwardRef(function ( { - element = "div" as T, + element, onClick, children, kind, @@ -141,10 +135,10 @@ const AccessibleButton = forwardRef(function , - ref: Ref, + }: ButtonProps, + ref: Ref, ): JSX.Element { - const newProps: RenderedElementProps = restProps; + const newProps = restProps as RenderedElementProps; newProps["aria-label"] = newProps["aria-label"] ?? title; if (disabled) { newProps["aria-disabled"] = true; @@ -162,7 +156,7 @@ const AccessibleButton = forwardRef(function { + newProps.onKeyDown = (e: KeyboardEvent) => { const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { @@ -178,7 +172,7 @@ const AccessibleButton = forwardRef(function { + newProps.onKeyUp = (e: KeyboardEvent) => { const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { @@ -207,7 +201,7 @@ const AccessibleButton = forwardRef(function { + ref?: Ref; +} + +interface ButtonComponent { + // With the explicit `element` prop + (props: { element?: C } & ButtonProps & RefProp): ReactElement; + // Without the explicit `element` prop + (props: ButtonProps<"div"> & RefProp<"div">): ReactElement; +} + +export default AccessibleButton as ButtonComponent; diff --git a/src/components/views/elements/EditableItemList.tsx b/src/components/views/elements/EditableItemList.tsx index dc6e6c09a1..ad2d9aceee 100644 --- a/src/components/views/elements/EditableItemList.tsx +++ b/src/components/views/elements/EditableItemList.tsx @@ -133,12 +133,7 @@ export default class EditableItemList

extends React.PureComponent - + {_t("action|add")} diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index c3dfb24bd1..a852122b75 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -31,7 +31,7 @@ class Emoji extends React.PureComponent { return ( onClick(ev, emoji)} + onClick={(ev: ButtonEvent) => onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" diff --git a/src/components/views/messages/MPollEndBody.tsx b/src/components/views/messages/MPollEndBody.tsx index 94671fea12..1129b3538e 100644 --- a/src/components/views/messages/MPollEndBody.tsx +++ b/src/components/views/messages/MPollEndBody.tsx @@ -90,7 +90,7 @@ export const MPollEndBody = React.forwardRef(({ mxEvent, ...pro const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent); if (!pollStartEvent) { - const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent, cli); + const pollEndFallbackMessage = M_TEXT.findIn(mxEvent.getContent()) || textForEvent(mxEvent, cli); return ( <> diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 9d21b8fa45..579db054e9 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -435,7 +435,7 @@ export default class MessageActionBar extends React.PureComponent this.onPinClick(e, isPinned)} + onClick={(e: ButtonEvent) => this.onPinClick(e, isPinned)} onContextMenu={(e: ButtonEvent) => this.onPinClick(e, isPinned)} key="pin" placement="left" diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx index 8ed6461d0a..6dc3ae48a2 100644 --- a/src/components/views/settings/SetIdServer.tsx +++ b/src/components/views/settings/SetIdServer.tsx @@ -407,7 +407,6 @@ export default class SetIdServer extends React.Component { forceValidity={this.state.error ? false : undefined} /> = Omit< - ComponentProps>, - "aria-label" | "title" | "kind" | "className" | "onClick" | "element" +type Props = Omit< + ButtonProps, + "aria-label" | "title" | "kind" | "className" | "element" > & { isExpanded: boolean; - onClick: () => void; }; -export const DeviceExpandDetailsButton = ({ +export const DeviceExpandDetailsButton = ({ isExpanded, - onClick, ...rest }: Props): JSX.Element => { const label = isExpanded ? _t("settings|sessions|hide_details") : _t("settings|sessions|show_details"); @@ -36,7 +34,6 @@ export const DeviceExpandDetailsButton = className={classNames("mx_DeviceExpandDetailsButton", { mx_DeviceExpandDetailsButton_expanded: isExpanded, })} - onClick={onClick} > diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx index 9ad7df31e9..3e86d779ff 100644 --- a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx @@ -268,7 +268,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> onChange={this.onPersonalRuleChanged} /> value={this.state.newList} onChange={this.onNewListChanged} /> - + {_t("action|subscribe")} diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index 8311e6728e..63a70a97cd 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -71,7 +71,6 @@ export const SpaceAvatar: React.FC avatarUploadRef.current?.click()} - alt="" /> avatarUploadRef.current?.click()} diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index af484445b4..73bb66af38 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -221,7 +221,7 @@ const CreateSpaceButton: React.FC { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); useEffect(() => { if (!isPanelCollapsed && menuDisplayed) { diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index cee4cf54ec..38329c39b7 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -30,7 +30,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent, ButtonProps as AccessibleButtonProps } from "../elements/AccessibleButton"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; @@ -39,8 +39,8 @@ import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -type ButtonProps = Omit< - ComponentProps>, +type ButtonProps = Omit< + AccessibleButtonProps, "title" | "onClick" | "size" | "element" > & { space?: Room; @@ -52,12 +52,12 @@ type ButtonProps = Omit< notificationState?: NotificationState; isNarrow?: boolean; size: string; - innerRef?: RefObject; + innerRef?: RefObject; ContextMenuComponent?: ComponentType>; onClick?(ev?: ButtonEvent): void; }; -export const SpaceButton = ({ +export const SpaceButton = ({ space, spaceKey: _spaceKey, className, @@ -72,8 +72,8 @@ export const SpaceButton = ({ ContextMenuComponent, ...props }: ButtonProps): JSX.Element => { - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(innerRef); - const [onFocus, isActive, ref] = useRovingTabIndex(handle); + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(innerRef); + const [onFocus, isActive, ref] = useRovingTabIndex(handle); const tabIndex = isActive ? 0 : -1; const spaceKey = _spaceKey ?? space?.roomId; diff --git a/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx index bdcd3713cb..105736d04e 100644 --- a/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewButtons.tsx @@ -69,7 +69,7 @@ interface IDropdownButtonProps extends ButtonProps { } const LegacyCallViewDropdownButton: React.FC = ({ state, deviceKinds, ...props }) => { - const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); + const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu(); const [hoveringDropdown, setHoveringDropdown] = useState(false); const classes = classNames("mx_LegacyCallViewButtons_button", "mx_LegacyCallViewButtons_dropdownButton", { diff --git a/src/stores/InitialCryptoSetupStore.ts b/src/stores/InitialCryptoSetupStore.ts new file mode 100644 index 0000000000..0c2e49f5ca --- /dev/null +++ b/src/stores/InitialCryptoSetupStore.ts @@ -0,0 +1,140 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import EventEmitter from "events"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { useEffect, useState } from "react"; + +import { createCrossSigning } from "../CreateCrossSigning"; +import { SdkContextClass } from "../contexts/SDKContext"; + +type Status = "in_progress" | "complete" | "error" | undefined; + +export const useInitialCryptoSetupStatus = (store: InitialCryptoSetupStore): Status => { + const [status, setStatus] = useState(store.getStatus()); + + useEffect(() => { + const update = (): void => { + setStatus(store.getStatus()); + }; + + store.on("update", update); + + return () => { + store.off("update", update); + }; + }, [store]); + + return status; +}; + +/** + * Logic for setting up crypto state that's done immediately after + * a user registers. Should be transparent to the user, not requiring + * interaction in most cases. + * As distinct from SetupEncryptionStore which is for setting up + * 4S or verifying the device, will always require interaction + * from the user in some form. + */ +export class InitialCryptoSetupStore extends EventEmitter { + private status: Status = undefined; + + private client?: MatrixClient; + private isTokenLogin?: boolean; + private stores?: SdkContextClass; + private onFinished?: (success: boolean) => void; + + public static sharedInstance(): InitialCryptoSetupStore { + if (!window.mxInitialCryptoStore) window.mxInitialCryptoStore = new InitialCryptoSetupStore(); + return window.mxInitialCryptoStore; + } + + public getStatus(): Status { + return this.status; + } + + /** + * Start the initial crypto setup process. + * + * @param {MatrixClient} client The client to use for the setup + * @param {boolean} isTokenLogin True if the user logged in via a token login, otherwise false + * @param {SdkContextClass} stores The stores to use for the setup + */ + public startInitialCryptoSetup( + client: MatrixClient, + isTokenLogin: boolean, + stores: SdkContextClass, + onFinished: (success: boolean) => void, + ): void { + this.client = client; + this.isTokenLogin = isTokenLogin; + this.stores = stores; + this.onFinished = onFinished; + + // We just start this process: it's progress is tracked by the events rather + // than returning a promise, so we don't bother. + this.doSetup().catch(() => logger.error("Initial crypto setup failed")); + } + + /** + * Retry the initial crypto setup process. + * + * If no crypto setup is currently in process, this will return false. + * + * @returns {boolean} True if a retry was initiated, otherwise false + */ + public retry(): boolean { + if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) return false; + + this.doSetup().catch(() => logger.error("Initial crypto setup failed")); + + return true; + } + + private reset(): void { + this.client = undefined; + this.isTokenLogin = undefined; + this.stores = undefined; + } + + private async doSetup(): Promise { + if (this.client === undefined || this.isTokenLogin === undefined || this.stores == undefined) { + throw new Error("No setup is in progress"); + } + + const cryptoApi = this.client.getCrypto(); + if (!cryptoApi) throw new Error("No crypto module found!"); + + this.status = "in_progress"; + this.emit("update"); + + try { + await createCrossSigning(this.client, this.isTokenLogin, this.stores.accountPasswordStore.getPassword()); + + this.reset(); + + this.status = "complete"; + this.emit("update"); + this.onFinished?.(true); + } catch (e) { + if (this.isTokenLogin) { + // ignore any failures, we are relying on grace period here + this.reset(); + + this.status = "complete"; + this.emit("update"); + this.onFinished?.(true); + + return; + } + logger.error("Error bootstrapping cross-signing", e); + this.status = "error"; + this.emit("update"); + } + } +} diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index 70c721b1ca..a13ba26f72 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -33,6 +33,11 @@ export enum Phase { ConfirmReset = 6, } +/** + * Logic for setting up 4S and/or verifying the user's device: a process requiring + * ongoing interaction with the user, as distinct from InitialCryptoSetupStore which + * a (usually) non-interactive process that happens immediately after registration. + */ export class SetupEncryptionStore extends EventEmitter { private started?: boolean; public phase?: Phase; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 5bc2ac7fc0..de7a71fa80 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -194,6 +194,7 @@ export class StopGapWidgetDriver extends WidgetDriver { EventType.CallSDPStreamMetadataChanged, EventType.CallSDPStreamMetadataChangedPrefix, EventType.CallReplaces, + EventType.CallEncryptionKeysPrefix, ]; for (const eventType of sendRecvToDevice) { this.allowedCapabilities.add( diff --git a/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx b/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx index 4d3d495a38..a589b55289 100644 --- a/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx +++ b/test/components/views/dialogs/security/InitialCryptoSetupDialog-test.tsx @@ -7,31 +7,22 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, screen, waitFor } from "jest-matrix-react"; -import { mocked } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { render, screen } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; -import { createCrossSigning } from "../../../../../src/CreateCrossSigning"; import { InitialCryptoSetupDialog } from "../../../../../src/components/views/dialogs/security/InitialCryptoSetupDialog"; -import { createTestClient } from "../../../../test-utils"; - -jest.mock("../../../../../src/CreateCrossSigning", () => ({ - createCrossSigning: jest.fn(), -})); +import { InitialCryptoSetupStore } from "../../../../../src/stores/InitialCryptoSetupStore"; describe("InitialCryptoSetupDialog", () => { - let client: MatrixClient; - let createCrossSigningResolve: () => void; - let createCrossSigningReject: (e: Error) => void; + const storeMock = { + getStatus: jest.fn(), + retry: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }; beforeEach(() => { - client = createTestClient(); - mocked(createCrossSigning).mockImplementation(() => { - return new Promise((resolve, reject) => { - createCrossSigningResolve = resolve; - createCrossSigningReject = reject; - }); - }); + jest.spyOn(InitialCryptoSetupStore, "sharedInstance").mockReturnValue(storeMock as any); }); afterEach(() => { @@ -39,93 +30,32 @@ describe("InitialCryptoSetupDialog", () => { jest.restoreAllMocks(); }); - it("should call createCrossSigning and show a spinner while it runs", async () => { + it("should show a spinner while the setup is in progress", async () => { const onFinished = jest.fn(); - render( - , - ); + storeMock.getStatus.mockReturnValue("in_progress"); + + render(); - expect(createCrossSigning).toHaveBeenCalledWith(client, false, "hunter2"); expect(screen.getByTestId("spinner")).toBeInTheDocument(); - - createCrossSigningResolve!(); - - await waitFor(() => expect(onFinished).toHaveBeenCalledWith(true)); }); - it("should display an error if createCrossSigning fails", async () => { - render( - , - ); + it("should display an error if setup has failed", async () => { + storeMock.getStatus.mockReturnValue("error"); - createCrossSigningReject!(new Error("generic error message")); + render(); await expect(await screen.findByRole("button", { name: "Retry" })).toBeInTheDocument(); }); - it("ignores failures when tokenLogin is true", async () => { + it("calls retry when retry button pressed", async () => { const onFinished = jest.fn(); + storeMock.getStatus.mockReturnValue("error"); - render( - , - ); + render(); - createCrossSigningReject!(new Error("generic error message")); + await userEvent.click(await screen.findByRole("button", { name: "Retry" })); - await waitFor(() => expect(onFinished).toHaveBeenCalledWith(false)); - }); - - it("cancels the dialog when the cancel button is clicked", async () => { - const onFinished = jest.fn(); - - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - const cancelButton = await screen.findByRole("button", { name: "Cancel" }); - cancelButton.click(); - - expect(onFinished).toHaveBeenCalledWith(false); - }); - - it("should retry when the retry button is clicked", async () => { - render( - , - ); - - createCrossSigningReject!(new Error("generic error message")); - - const retryButton = await screen.findByRole("button", { name: "Retry" }); - retryButton.click(); - - expect(createCrossSigning).toHaveBeenCalledTimes(2); + expect(storeMock.retry).toHaveBeenCalled(); }); }); diff --git a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap index 94c2678388..1bdbe016d4 100644 --- a/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MatrixChat-test.tsx.snap @@ -314,7 +314,6 @@ exports[` with a soft-logged-out session should show the soft-logo class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary" role="button" tabindex="0" - type="submit" > Sign in diff --git a/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index 5fb1e66115..a4496312f3 100644 --- a/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/unit-tests/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -135,8 +135,9 @@ exports[` has button to edit topic 1`] = ` style="--mx-box-flex: 1;" >

renders the room summary 1`] = ` style="--mx-box-flex: 1;" >

renders the room topic in the summary 1`] = ` style="--mx-box-flex: 1;" >

{ describe("TooltipText", () => { @@ -87,6 +91,10 @@ describe("ReadReceiptGroup", () => { describe("", () => { stubClient(); + // We pick a fixed time but this can still vary depending on the locale + // the tests are run in. We are not testing date formatting here, so stub it out. + mocked(formatDate).mockReturnValue("==MOCK FORMATTED DATE=="); + const ROOM_ID = "roomId"; const USER_ID = "@alice:example.org"; diff --git a/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap b/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap index b0ba944a66..60e8f844af 100644 --- a/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap +++ b/test/unit-tests/components/views/rooms/__snapshots__/ReadReceiptGroup-test.tsx.snap @@ -84,7 +84,7 @@ exports[`ReadReceiptGroup should render 1`] = `

- Wed, 15 May, 0:00 + ==MOCK FORMATTED DATE==

diff --git a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx index 888499d524..5c77e88d93 100644 --- a/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx +++ b/test/unit-tests/components/views/settings/SetIntegrationManager-test.tsx @@ -35,7 +35,7 @@ describe("SetIntegrationManager", () => { deleteThreePid: jest.fn(), }); - let stores: SdkContextClass; + let stores!: SdkContextClass; const getComponent = () => ( diff --git a/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap b/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap index fcf3406620..2aa08adb94 100644 --- a/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap +++ b/test/unit-tests/components/views/settings/__snapshots__/LayoutSwitcher-test.tsx.snap @@ -19,14 +19,14 @@ exports[` should render 1`] = ` class="mx_SettingsSubsection_content mx_SettingsSubsection_content_newUi" >