Merge branch 'develop' of https://github.com/vector-im/element-web into dbkr/stateafter
# Conflicts: # test/unit-tests/components/structures/RoomView-test.tsx # test/unit-tests/components/structures/TimelinePanel-test.tsx
This commit is contained in:
@@ -14,14 +14,6 @@ export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor
|
||||
|
||||
export type { Leaves } from "matrix-web-i18n";
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: T[P] extends (infer U)[]
|
||||
? RecursivePartial<U>[]
|
||||
: T[P] extends object
|
||||
? RecursivePartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
export type KeysStartingWith<Input extends object, Str extends string> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
|
||||
import "@types/modernizr";
|
||||
|
||||
import type { Renderer } from "react-dom";
|
||||
import type { logger } from "matrix-js-sdk/src/logger";
|
||||
import ContentMessages from "../ContentMessages";
|
||||
import { IMatrixClientPeg } from "../MatrixClientPeg";
|
||||
@@ -44,6 +43,7 @@ import AutoRageshakeStore from "../stores/AutoRageshakeStore";
|
||||
import { IConfigOptions } from "../IConfigOptions";
|
||||
import { MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||
import { DeepReadonly } from "./common";
|
||||
import MatrixChat from "../components/structures/MatrixChat";
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
@@ -71,7 +71,7 @@ declare global {
|
||||
interface Window {
|
||||
mxSendRageshake: (text: string, withLogs?: boolean) => void;
|
||||
matrixLogger: typeof logger;
|
||||
matrixChat: ReturnType<Renderer>;
|
||||
matrixChat?: MatrixChat;
|
||||
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;
|
||||
mxLoginWithAccessToken: (hsUrl: string, accessToken: string) => Promise<void>;
|
||||
mxAutoRageshakeStore?: AutoRageshakeStore;
|
||||
|
||||
Vendored
+7
@@ -22,6 +22,13 @@ declare module "matrix-js-sdk/src/types" {
|
||||
[BLURHASH_FIELD]?: string;
|
||||
}
|
||||
|
||||
export interface ImageInfo {
|
||||
/**
|
||||
* @see https://github.com/matrix-org/matrix-spec-proposals/pull/4230
|
||||
*/
|
||||
"org.matrix.msc4230.is_animated"?: boolean;
|
||||
}
|
||||
|
||||
export interface StateEvents {
|
||||
// Jitsi-backed video room state events
|
||||
[JitsiCallMemberEventType]: JitsiCallMemberContent;
|
||||
|
||||
+9
-5
@@ -10,13 +10,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import {
|
||||
IAddThreePidOnlyBody,
|
||||
IAuthData,
|
||||
IRequestMsisdnTokenResponse,
|
||||
IRequestTokenResponse,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
HTTPError,
|
||||
IThreepid,
|
||||
UIAResponse,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "./Modal";
|
||||
@@ -179,7 +179,9 @@ export default class AddThreepid {
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
public async checkEmailLinkClicked(): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
|
||||
public async checkEmailLinkClicked(): Promise<
|
||||
[success?: boolean, result?: UIAResponse<IAddThreePidOnlyBody> | Error | null]
|
||||
> {
|
||||
try {
|
||||
if (this.bind) {
|
||||
const authClient = new IdentityAuthClient();
|
||||
@@ -220,7 +222,7 @@ export default class AddThreepid {
|
||||
continueKind: "primary",
|
||||
},
|
||||
};
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, {
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog<IAddThreePidOnlyBody>, {
|
||||
title: _t("settings|general|add_email_dialog_title"),
|
||||
matrixClient: this.matrixClient,
|
||||
authData: err.data,
|
||||
@@ -263,7 +265,9 @@ export default class AddThreepid {
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
|
||||
public async haveMsisdnToken(
|
||||
msisdnToken: string,
|
||||
): Promise<[success?: boolean, result?: UIAResponse<IAddThreePidOnlyBody> | Error | null]> {
|
||||
const authClient = new IdentityAuthClient();
|
||||
|
||||
if (this.submitUrl) {
|
||||
@@ -319,7 +323,7 @@ export default class AddThreepid {
|
||||
continueKind: "primary",
|
||||
},
|
||||
};
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog<{}>, {
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog<IAddThreePidOnlyBody>, {
|
||||
title: _t("settings|general|add_msisdn_dialog_title"),
|
||||
matrixClient: this.matrixClient,
|
||||
authData: err.data,
|
||||
|
||||
+9
-44
@@ -6,24 +6,19 @@ 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, { ComponentType, PropsWithChildren } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
|
||||
import { _t } from "./languageHandler";
|
||||
import BaseDialog from "./components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "./components/views/elements/DialogButtons";
|
||||
import Spinner from "./components/views/elements/Spinner";
|
||||
|
||||
type AsyncImport<T> = { default: T };
|
||||
|
||||
interface IProps {
|
||||
// A promise which resolves with the real component
|
||||
prom: Promise<ComponentType<any> | AsyncImport<ComponentType<any>>>;
|
||||
onFinished(): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
component?: ComponentType<PropsWithChildren<any>>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@@ -32,56 +27,26 @@ interface IState {
|
||||
* spinner until the real component loads.
|
||||
*/
|
||||
export default class AsyncWrapper extends React.Component<IProps, IState> {
|
||||
private unmounted = false;
|
||||
public static getDerivedStateFromError(error: Error): IState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public state: IState = {};
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
this.props.prom
|
||||
.then((result) => {
|
||||
if (this.unmounted) return;
|
||||
|
||||
// Take the 'default' member if it's there, then we support
|
||||
// passing in just an import()ed module, since ES6 async import
|
||||
// always returns a module *namespace*.
|
||||
const component = (result as AsyncImport<ComponentType>).default
|
||||
? (result as AsyncImport<ComponentType>).default
|
||||
: (result as ComponentType);
|
||||
this.setState({ component });
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.warn("AsyncWrapper promise failed", e);
|
||||
this.setState({ error: e });
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
private onWrapperCancelClick = (): void => {
|
||||
this.props.onFinished();
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
if (this.state.component) {
|
||||
const Component = this.state.component;
|
||||
return <Component {...this.props} />;
|
||||
} else if (this.state.error) {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<BaseDialog onFinished={this.props.onFinished} title={_t("common|error")}>
|
||||
{_t("failed_load_async_component")}
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|dismiss")}
|
||||
onPrimaryButtonClick={this.onWrapperCancelClick}
|
||||
onPrimaryButtonClick={this.props.onFinished}
|
||||
hasCancel={false}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
} else {
|
||||
// show a spinner until the component is loaded.
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return <Suspense fallback={<Spinner />}>{this.props.children}</Suspense>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
// @ts-ignore - `.ts` is needed here to make TS happy
|
||||
import { Request, Response } from "./workers/blurhash.worker.ts";
|
||||
import { WorkerManager } from "./WorkerManager";
|
||||
import blurhashWorkerFactory from "./workers/blurhashWorkerFactory";
|
||||
|
||||
@@ -56,6 +56,7 @@ import { createThumbnail } from "./utils/image-media";
|
||||
import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer";
|
||||
import { doMaybeLocalRoomAction } from "./utils/local-room";
|
||||
import { SdkContextClass } from "./contexts/SDKContext";
|
||||
import { blobIsAnimated } from "./utils/Image.ts";
|
||||
|
||||
// scraped out of a macOS hidpi (5660ppm) screenshot png
|
||||
// 5669 px (x-axis) , 5669 px (y-axis) , per metre
|
||||
@@ -150,15 +151,20 @@ async function infoForImageFile(matrixClient: MatrixClient, roomId: string, imag
|
||||
thumbnailType = "image/jpeg";
|
||||
}
|
||||
|
||||
// We don't await this immediately so it can happen in the background
|
||||
const isAnimatedPromise = blobIsAnimated(imageFile.type, imageFile);
|
||||
|
||||
const imageElement = await loadImageElement(imageFile);
|
||||
|
||||
const result = await createThumbnail(imageElement.img, imageElement.width, imageElement.height, thumbnailType);
|
||||
const imageInfo = result.info;
|
||||
|
||||
imageInfo["org.matrix.msc4230.is_animated"] = await isAnimatedPromise;
|
||||
|
||||
// For lesser supported image types, always include the thumbnail even if it is larger
|
||||
if (!ALWAYS_INCLUDE_THUMBNAIL.includes(imageFile.type)) {
|
||||
// we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from.
|
||||
const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size;
|
||||
const sizeDifference = imageFile.size - imageInfo.thumbnail_info!.size!;
|
||||
if (
|
||||
// image is small enough already
|
||||
imageFile.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL ||
|
||||
@@ -536,9 +542,7 @@ export default class ContentMessages {
|
||||
attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent);
|
||||
attachRelation(content, relation);
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
includeLegacyFallback: false,
|
||||
});
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
|
||||
|
||||
+11
-4
@@ -46,6 +46,7 @@ import SettingsStore, { CallbackFn } from "./settings/SettingsStore";
|
||||
import { UIFeature } from "./settings/UIFeature";
|
||||
import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder";
|
||||
import { getUserDeviceIds } from "./utils/crypto/deviceInfo";
|
||||
import { asyncSomeParallel } from "./utils/arrays.ts";
|
||||
|
||||
const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
@@ -229,24 +230,30 @@ export default class DeviceListener {
|
||||
private async getKeyBackupInfo(): Promise<KeyBackupInfo | null> {
|
||||
if (!this.client) return null;
|
||||
const now = new Date().getTime();
|
||||
const crypto = this.client.getCrypto();
|
||||
if (!crypto) return null;
|
||||
|
||||
if (
|
||||
!this.keyBackupInfo ||
|
||||
!this.keyBackupFetchedAt ||
|
||||
this.keyBackupFetchedAt < now - KEY_BACKUP_POLL_INTERVAL
|
||||
) {
|
||||
this.keyBackupInfo = await this.client.getKeyBackupVersion();
|
||||
this.keyBackupInfo = await crypto.getKeyBackupInfo();
|
||||
this.keyBackupFetchedAt = now;
|
||||
}
|
||||
return this.keyBackupInfo;
|
||||
}
|
||||
|
||||
private shouldShowSetupEncryptionToast(): boolean {
|
||||
private async shouldShowSetupEncryptionToast(): Promise<boolean> {
|
||||
// If we're in the middle of a secret storage operation, we're likely
|
||||
// modifying the state involved here, so don't add new toasts to setup.
|
||||
if (isSecretStorageBeingAccessed()) return false;
|
||||
// Show setup toasts once the user is in at least one encrypted room.
|
||||
const cli = this.client;
|
||||
return cli?.getRooms().some((r) => cli.isRoomEncrypted(r.roomId)) ?? false;
|
||||
const cryptoApi = cli?.getCrypto();
|
||||
if (!cli || !cryptoApi) return false;
|
||||
|
||||
return await asyncSomeParallel(cli.getRooms(), ({ roomId }) => cryptoApi.isEncryptionEnabledInRoom(roomId));
|
||||
}
|
||||
|
||||
private recheck(): void {
|
||||
@@ -283,7 +290,7 @@ export default class DeviceListener {
|
||||
hideSetupEncryptionToast();
|
||||
|
||||
this.checkKeyBackupStatus();
|
||||
} else if (this.shouldShowSetupEncryptionToast()) {
|
||||
} else if (await this.shouldShowSetupEncryptionToast()) {
|
||||
// make sure our keys are finished downloading
|
||||
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
|
||||
|
||||
|
||||
+30
-49
@@ -14,7 +14,7 @@ import { IDeferred, defer } from "matrix-js-sdk/src/utils";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
|
||||
import { Glass, TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import dis, { defaultDispatcher } from "./dispatcher/dispatcher";
|
||||
import defaultDispatcher from "./dispatcher/dispatcher";
|
||||
import AsyncWrapper from "./AsyncWrapper";
|
||||
import { Defaultize } from "./@types/common";
|
||||
import { ActionPayload } from "./dispatcher/payloads";
|
||||
@@ -136,32 +136,6 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
return !!this.priorityModal || !!this.staticModal || this.modals.length > 0;
|
||||
}
|
||||
|
||||
public createDialog<C extends ComponentType>(
|
||||
Element: C,
|
||||
props?: ComponentProps<C>,
|
||||
className?: string,
|
||||
isPriorityModal = false,
|
||||
isStaticModal = false,
|
||||
options: IOptions<C> = {},
|
||||
): IHandle<C> {
|
||||
return this.createDialogAsync<C>(
|
||||
Promise.resolve(Element),
|
||||
props,
|
||||
className,
|
||||
isPriorityModal,
|
||||
isStaticModal,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
public appendDialog<C extends ComponentType>(
|
||||
Element: C,
|
||||
props?: ComponentProps<C>,
|
||||
className?: string,
|
||||
): IHandle<C> {
|
||||
return this.appendDialogAsync<C>(Promise.resolve(Element), props, className);
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED.
|
||||
* This is used only for tests. They should be using forceCloseAllModals but that
|
||||
@@ -196,8 +170,11 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
this.reRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* @typeParam C - the component type
|
||||
*/
|
||||
private buildModal<C extends ComponentType>(
|
||||
prom: Promise<C>,
|
||||
Component: C,
|
||||
props?: ComponentProps<C>,
|
||||
className?: string,
|
||||
options?: IOptions<C>,
|
||||
@@ -222,9 +199,12 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
// otherwise we'll get confused.
|
||||
const modalCount = this.counter++;
|
||||
|
||||
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
|
||||
// property set here so you can't close the dialog from a button click!
|
||||
modal.elem = <AsyncWrapper key={modalCount} prom={prom} {...props} onFinished={closeDialog} />;
|
||||
// Typescript doesn't like us passing props as any here, but we know that they are well typed due to the rigorous generics.
|
||||
modal.elem = (
|
||||
<AsyncWrapper key={modalCount} onFinished={closeDialog}>
|
||||
<Component {...(props as any)} onFinished={closeDialog} />
|
||||
</AsyncWrapper>
|
||||
);
|
||||
modal.close = closeDialog;
|
||||
|
||||
return { modal, closeDialog, onFinishedProm };
|
||||
@@ -291,29 +271,30 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
* require(['<module>'], cb);
|
||||
* }
|
||||
*
|
||||
* @param {Promise} prom a promise which resolves with a React component
|
||||
* which will be displayed as the modal view.
|
||||
* @param component The component to render as a dialog. This component must accept an `onFinished` prop function as
|
||||
* per the type {@link ComponentType}. If loading a component with esoteric dependencies consider
|
||||
* using React.lazy to async load the component.
|
||||
* e.g. `lazy(() => import('./MyComponent'))`
|
||||
*
|
||||
* @param {Object} props properties to pass to the displayed
|
||||
* component. (We will also pass an 'onFinished' property.)
|
||||
* @param props properties to pass to the displayed component. (We will also pass an 'onFinished' property.)
|
||||
*
|
||||
* @param {String} className CSS class to apply to the modal wrapper
|
||||
* @param className CSS class to apply to the modal wrapper
|
||||
*
|
||||
* @param {boolean} isPriorityModal if true, this modal will be displayed regardless
|
||||
* @param isPriorityModal if true, this modal will be displayed regardless
|
||||
* of other modals that are currently in the stack.
|
||||
* Also, when closed, all modals will be removed
|
||||
* from the stack.
|
||||
* @param {boolean} isStaticModal if true, this modal will be displayed under other
|
||||
* @param isStaticModal if true, this modal will be displayed under other
|
||||
* modals in the stack. When closed, all modals will
|
||||
* also be removed from the stack. This is not compatible
|
||||
* with being a priority modal. Only one modal can be
|
||||
* static at a time.
|
||||
* @param {Object} options? extra options for the dialog
|
||||
* @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog
|
||||
* @returns {object} Object with 'close' parameter being a function that will close the dialog
|
||||
* @param options? extra options for the dialog
|
||||
* @param options.onBeforeClose a callback to decide whether to close the dialog
|
||||
* @returns Object with 'close' parameter being a function that will close the dialog
|
||||
*/
|
||||
public createDialogAsync<C extends ComponentType>(
|
||||
prom: Promise<C>,
|
||||
public createDialog<C extends ComponentType>(
|
||||
component: C,
|
||||
props?: ComponentProps<C>,
|
||||
className?: string,
|
||||
isPriorityModal = false,
|
||||
@@ -321,7 +302,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
options: IOptions<C> = {},
|
||||
): IHandle<C> {
|
||||
const beforeModal = this.getCurrentModal();
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, options);
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(component, props, className, options);
|
||||
if (isPriorityModal) {
|
||||
// XXX: This is destructive
|
||||
this.priorityModal = modal;
|
||||
@@ -341,13 +322,13 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
};
|
||||
}
|
||||
|
||||
private appendDialogAsync<C extends ComponentType>(
|
||||
prom: Promise<C>,
|
||||
public appendDialog<C extends ComponentType>(
|
||||
component: C,
|
||||
props?: ComponentProps<C>,
|
||||
className?: string,
|
||||
): IHandle<C> {
|
||||
const beforeModal = this.getCurrentModal();
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(prom, props, className, {});
|
||||
const { modal, closeDialog, onFinishedProm } = this.buildModal<C>(component, props, className, {});
|
||||
|
||||
this.modals.push(modal);
|
||||
|
||||
@@ -396,7 +377,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
|
||||
// If there is no modal to render, make all of Element available
|
||||
// to screen reader users again
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "aria_unhide_main_app",
|
||||
});
|
||||
ModalManager.getOrCreateRoot().render(<></>);
|
||||
@@ -407,7 +388,7 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
|
||||
// Hide the content outside the modal to screen reader users
|
||||
// so they won't be able to navigate into it and act on it using
|
||||
// screen reader specific features
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "aria_hide_main_app",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
// @ts-ignore - `.ts` is needed here to make TS happy
|
||||
import { Request, Response } from "./workers/playback.worker";
|
||||
import { WorkerManager } from "./WorkerManager";
|
||||
import playbackWorkerFactory from "./workers/playbackWorkerFactory";
|
||||
|
||||
@@ -514,7 +514,7 @@ function getWidgets(event: MessageEvent<any>, roomId: string | null): void {
|
||||
sendResponse(event, widgetStateEvents);
|
||||
}
|
||||
|
||||
function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
|
||||
async function getRoomEncState(event: MessageEvent<any>, roomId: string): Promise<void> {
|
||||
const client = MatrixClientPeg.get();
|
||||
if (!client) {
|
||||
sendError(event, _t("widget|error_need_to_be_logged_in"));
|
||||
@@ -525,7 +525,7 @@ function getRoomEncState(event: MessageEvent<any>, roomId: string): void {
|
||||
sendError(event, _t("scalar|error_room_unknown"));
|
||||
return;
|
||||
}
|
||||
const roomIsEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
|
||||
const roomIsEncrypted = Boolean(await client.getCrypto()?.isEncryptionEnabledInRoom(roomId));
|
||||
|
||||
sendResponse(event, roomIsEncrypted);
|
||||
}
|
||||
|
||||
+2
-2
@@ -596,7 +596,7 @@ async function combinedPagination(
|
||||
return result;
|
||||
}
|
||||
|
||||
function eventIndexSearch(
|
||||
async function eventIndexSearch(
|
||||
client: MatrixClient,
|
||||
term: string,
|
||||
roomId?: string,
|
||||
@@ -605,7 +605,7 @@ function eventIndexSearch(
|
||||
let searchPromise: Promise<ISearchResults>;
|
||||
|
||||
if (roomId !== undefined) {
|
||||
if (client.isRoomEncrypted(roomId)) {
|
||||
if (await client.getCrypto()?.isEncryptionEnabledInRoom(roomId)) {
|
||||
// The search is for a single encrypted room, use our local
|
||||
// search method.
|
||||
searchPromise = localSearchProcess(client, term, roomId);
|
||||
|
||||
+21
-13
@@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { lazy } from "react";
|
||||
import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import type CreateSecretStorageDialog from "./async-components/views/dialogs/security/CreateSecretStorageDialog";
|
||||
import Modal from "./Modal";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { _t } from "./languageHandler";
|
||||
@@ -186,6 +186,15 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccessSecretStorageOpts {
|
||||
/** Reset secret storage even if it's already set up. */
|
||||
forceReset?: boolean;
|
||||
/** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
|
||||
resetCrossSigning?: boolean;
|
||||
/** The cached account password, if available. */
|
||||
accountPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This helper should be used whenever you need to access secret storage. It
|
||||
* ensures that secret storage (and also cross-signing since they each depend on
|
||||
@@ -205,14 +214,17 @@ export async function withSecretStorageKeyCache<T>(func: () => Promise<T>): Prom
|
||||
*
|
||||
* @param {Function} [func] An operation to perform once secret storage has been
|
||||
* bootstrapped. Optional.
|
||||
* @param {bool} [forceReset] Reset secret storage even if it's already set up
|
||||
* @param [opts] The options to use when accessing secret storage.
|
||||
*/
|
||||
export async function accessSecretStorage(func = async (): Promise<void> => {}, forceReset = false): Promise<void> {
|
||||
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, forceReset));
|
||||
export async function accessSecretStorage(
|
||||
func = async (): Promise<void> => {},
|
||||
opts: AccessSecretStorageOpts = {},
|
||||
): Promise<void> {
|
||||
await withSecretStorageKeyCache(() => doAccessSecretStorage(func, opts));
|
||||
}
|
||||
|
||||
/** Helper for {@link #accessSecretStorage} */
|
||||
async function doAccessSecretStorage(func: () => Promise<void>, forceReset: boolean): Promise<void> {
|
||||
async function doAccessSecretStorage(func: () => Promise<void>, opts: AccessSecretStorageOpts): Promise<void> {
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto();
|
||||
@@ -221,7 +233,7 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
|
||||
}
|
||||
|
||||
let createNew = false;
|
||||
if (forceReset) {
|
||||
if (opts.forceReset) {
|
||||
logger.debug("accessSecretStorage: resetting 4S");
|
||||
createNew = true;
|
||||
} else if (!(await cli.secretStorage.hasKey())) {
|
||||
@@ -232,13 +244,9 @@ async function doAccessSecretStorage(func: () => Promise<void>, forceReset: bool
|
||||
if (createNew) {
|
||||
// This dialog calls bootstrap itself after guiding the user through
|
||||
// passphrase creation.
|
||||
const { finished } = Modal.createDialogAsync(
|
||||
import("./async-components/views/dialogs/security/CreateSecretStorageDialog") as unknown as Promise<
|
||||
typeof CreateSecretStorageDialog
|
||||
>,
|
||||
{
|
||||
forceReset,
|
||||
},
|
||||
const { finished } = Modal.createDialog(
|
||||
lazy(() => import("./async-components/views/dialogs/security/CreateSecretStorageDialog")),
|
||||
opts,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
|
||||
@@ -229,7 +229,7 @@ export class SlidingSyncManager {
|
||||
subscriptions.delete(roomId);
|
||||
}
|
||||
const room = this.client?.getRoom(roomId);
|
||||
let shouldLazyLoad = !this.client?.isRoomEncrypted(roomId);
|
||||
let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId));
|
||||
if (!room) {
|
||||
// default to safety: request all state if we can't work it out. This can happen if you
|
||||
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
|
||||
|
||||
@@ -10,7 +10,6 @@ import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
@@ -18,11 +17,12 @@ import React, {
|
||||
Dispatch,
|
||||
RefObject,
|
||||
ReactNode,
|
||||
RefCallback,
|
||||
} from "react";
|
||||
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
import { FocusHandler, Ref } from "./roving/types";
|
||||
import { FocusHandler } from "./roving/types";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
@@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
activeRef?: Ref;
|
||||
refs: Ref[];
|
||||
activeNode?: HTMLElement;
|
||||
nodes: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface IContext {
|
||||
@@ -60,7 +60,7 @@ export interface IContext {
|
||||
|
||||
export const RovingTabIndexContext = createContext<IContext>({
|
||||
state: {
|
||||
refs: [], // list of refs in DOM order
|
||||
nodes: [], // list of nodes in DOM order
|
||||
},
|
||||
dispatch: () => {},
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export enum Type {
|
||||
export interface IAction {
|
||||
type: Exclude<Type, Type.Update>;
|
||||
payload: {
|
||||
ref: Ref;
|
||||
node: HTMLElement;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,12 +87,12 @@ interface UpdateAction {
|
||||
|
||||
type Action = IAction | UpdateAction;
|
||||
|
||||
const refSorter = (a: Ref, b: Ref): number => {
|
||||
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.current!.compareDocumentPosition(b.current!);
|
||||
const position = a.compareDocumentPosition(b);
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||
return -1;
|
||||
@@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
|
||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Type.Register: {
|
||||
if (!state.activeRef) {
|
||||
// Our list of refs was empty, set activeRef to this first item
|
||||
state.activeRef = action.payload.ref;
|
||||
if (!state.activeNode) {
|
||||
// Our list of nodes was empty, set activeNode to this first item
|
||||
state.activeNode = action.payload.node;
|
||||
}
|
||||
|
||||
if (state.nodes.includes(action.payload.node)) return state;
|
||||
|
||||
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
|
||||
state.refs.push(action.payload.ref);
|
||||
state.refs.sort(refSorter);
|
||||
state.nodes.push(action.payload.node);
|
||||
state.nodes.sort(nodeSorter);
|
||||
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Unregister: {
|
||||
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
|
||||
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
|
||||
|
||||
if (oldIndex === -1) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
|
||||
// we just removed the active ref, need to replace it
|
||||
// pick the ref closest to the index the old ref was in
|
||||
if (oldIndex >= state.refs.length) {
|
||||
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
|
||||
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
|
||||
// we just removed the active node, need to replace it
|
||||
// pick the node closest to the index the old node was in
|
||||
if (oldIndex >= state.nodes.length) {
|
||||
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
|
||||
} else {
|
||||
state.activeRef =
|
||||
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
|
||||
state.activeNode =
|
||||
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
|
||||
}
|
||||
if (document.activeElement === document.body) {
|
||||
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
||||
setTimeout(() => state.activeRef?.current?.focus(), 0);
|
||||
setTimeout(() => state.activeNode?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// update the refs list
|
||||
// update the nodes list
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.SetFocus: {
|
||||
// if the ref doesn't change just return the same object reference to skip a re-render
|
||||
if (state.activeRef === action.payload.ref) return state;
|
||||
// update active ref
|
||||
state.activeRef = action.payload.ref;
|
||||
// if the node doesn't change just return the same object reference to skip a re-render
|
||||
if (state.activeNode === action.payload.node) return state;
|
||||
// update active node
|
||||
state.activeNode = action.payload.node;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Update: {
|
||||
state.refs.sort(refSorter);
|
||||
state.nodes.sort(nodeSorter);
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
@@ -174,28 +176,28 @@ interface IProps {
|
||||
}
|
||||
|
||||
export const findSiblingElement = (
|
||||
refs: RefObject<HTMLElement>[],
|
||||
nodes: HTMLElement[],
|
||||
startIndex: number,
|
||||
backwards = false,
|
||||
loop = false,
|
||||
): RefObject<HTMLElement> | undefined => {
|
||||
): HTMLElement | undefined => {
|
||||
if (backwards) {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i--) {
|
||||
if (refs[i].current?.offsetParent !== null) {
|
||||
return refs[i];
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false);
|
||||
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i++) {
|
||||
if (refs[i].current?.offsetParent !== null) {
|
||||
return refs[i];
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(refs.slice(0, startIndex), 0, false, false);
|
||||
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -211,7 +213,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
|
||||
refs: [],
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
@@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
|
||||
let handled = false;
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
let focusRef: RefObject<HTMLElement> | undefined;
|
||||
let focusNode: HTMLElement | undefined;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
// but allow people to move focus from it with Tab.
|
||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
||||
switch (action) {
|
||||
case KeyBindingAction.Tab:
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
||||
focusRef = findSiblingElement(
|
||||
context.state.refs,
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(
|
||||
context.state.nodes,
|
||||
idx + (ev.shiftKey ? -1 : 1),
|
||||
ev.shiftKey,
|
||||
);
|
||||
@@ -251,7 +253,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first (visible) item
|
||||
focusRef = findSiblingElement(context.state.refs, 0);
|
||||
focusNode = findSiblingElement(context.state.nodes, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -259,7 +261,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last (visible) item
|
||||
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
|
||||
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
||||
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
||||
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (focusRef) {
|
||||
focusRef.current?.focus();
|
||||
if (focusNode) {
|
||||
focusNode?.focus();
|
||||
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
ref: focusRef,
|
||||
node: focusNode,
|
||||
},
|
||||
});
|
||||
if (scrollIntoView) {
|
||||
focusRef.current?.scrollIntoView(scrollIntoView);
|
||||
focusNode?.scrollIntoView(scrollIntoView);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -337,46 +339,61 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to register a roving tab index
|
||||
// inputRef parameter specifies the ref to use
|
||||
// onFocus should be called when the index gained focus in any manner
|
||||
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||
/**
|
||||
* Hook to register a roving tab index.
|
||||
*
|
||||
* inputRef is an optional argument; when passed this ref points to the DOM element
|
||||
* to which the callback ref is attached.
|
||||
*
|
||||
* Returns:
|
||||
* onFocus should be called when the index gained focus in any manner.
|
||||
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
|
||||
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
|
||||
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
|
||||
*
|
||||
* nodeRef = inputRef when inputRef argument is provided.
|
||||
*/
|
||||
export const useRovingTabIndex = <T extends HTMLElement>(
|
||||
inputRef?: RefObject<T>,
|
||||
): [FocusHandler, boolean, RefObject<T>] => {
|
||||
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
let ref = useRef<T>(null);
|
||||
|
||||
let nodeRef = useRef<T | null>(null);
|
||||
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
ref = inputRef;
|
||||
nodeRef = inputRef;
|
||||
}
|
||||
|
||||
// setup (after refs)
|
||||
useEffect(() => {
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: { ref },
|
||||
});
|
||||
// teardown
|
||||
return () => {
|
||||
const ref = useCallback((node: T | null) => {
|
||||
if (node) {
|
||||
nodeRef.current = node;
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: { node },
|
||||
});
|
||||
} else {
|
||||
context.dispatch({
|
||||
type: Type.Unregister,
|
||||
payload: { ref },
|
||||
payload: { node: nodeRef.current! },
|
||||
});
|
||||
};
|
||||
nodeRef.current = null;
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
if (!nodeRef.current) {
|
||||
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
|
||||
return;
|
||||
}
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
payload: { node: nodeRef.current },
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isActive = context.state.activeRef === ref;
|
||||
return [onFocus, isActive, ref];
|
||||
const isActive = context.state.activeNode === nodeRef.current;
|
||||
return [onFocus, isActive, ref, nodeRef];
|
||||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
|
||||
@@ -6,14 +6,18 @@ 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, { ReactElement } from "react";
|
||||
import React, { ReactElement, RefCallback } from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { FocusHandler, Ref } from "./types";
|
||||
|
||||
interface IProps {
|
||||
inputRef?: Ref;
|
||||
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>;
|
||||
children(renderProps: {
|
||||
onFocus: FocusHandler;
|
||||
isActive: boolean;
|
||||
ref: RefCallback<HTMLElement>;
|
||||
}): ReactElement<any, any>;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
|
||||
@@ -58,6 +58,7 @@ interface IProps {
|
||||
hasCancel?: boolean;
|
||||
accountPassword?: string;
|
||||
forceReset?: boolean;
|
||||
resetCrossSigning?: boolean;
|
||||
onFinished(ok?: boolean): void;
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
hasCancel: true,
|
||||
forceReset: false,
|
||||
resetCrossSigning: false,
|
||||
};
|
||||
private recoveryKey?: GeneratedSecretStorageKey;
|
||||
private recoveryKeyNode = createRef<HTMLElement>();
|
||||
@@ -270,14 +272,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
private bootstrapSecretStorage = async (): Promise<void> => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto()!;
|
||||
const { forceReset } = this.props;
|
||||
const { forceReset, resetCrossSigning } = this.props;
|
||||
|
||||
let backupInfo;
|
||||
// First, unless we know we want to do a reset, we see if there is an existing key backup
|
||||
if (!forceReset) {
|
||||
try {
|
||||
this.setState({ phase: Phase.Loading });
|
||||
backupInfo = await cli.getKeyBackupVersion();
|
||||
backupInfo = await crypto.getKeyBackupInfo();
|
||||
} catch (e) {
|
||||
logger.error("Error fetching backup data from server", e);
|
||||
this.setState({ phase: Phase.LoadError });
|
||||
@@ -292,12 +294,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent<IProp
|
||||
|
||||
try {
|
||||
if (forceReset) {
|
||||
/* Resetting cross-signing requires secret storage to be reset
|
||||
* (otherwise it will try to store the cross-signing keys in the
|
||||
* old secret storage, and may prompt for the old key, which is
|
||||
* probably not available), and resetting key backup requires
|
||||
* cross-signing to be reset (so that the new backup can be
|
||||
* signed by the new cross-signing key). So we reset secret
|
||||
* storage first, then cross-signing, then key backup.
|
||||
*/
|
||||
logger.log("Forcing secret storage reset");
|
||||
await crypto.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => this.recoveryKey!,
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
if (resetCrossSigning) {
|
||||
logger.log("Resetting cross signing");
|
||||
await crypto.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
}
|
||||
logger.log("Resetting key backup");
|
||||
await crypto.resetKeyBackup();
|
||||
} else {
|
||||
// For password authentication users after 2020-09, this cross-signing
|
||||
// step will be a no-op since it is now setup during registration or login
|
||||
|
||||
@@ -28,7 +28,7 @@ interface NewRecoveryMethodDialogProps {
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
// Export as default instead of a named export so that it can be dynamically imported with `Modal.createDialogAsync`
|
||||
// Export as default instead of a named export so that it can be dynamically imported with React lazy
|
||||
|
||||
/**
|
||||
* Dialog to inform the user that a new recovery method has been detected.
|
||||
|
||||
@@ -7,11 +7,11 @@ 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 from "react";
|
||||
import React, { lazy } from "react";
|
||||
|
||||
import dis from "../../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Modal, { ComponentType } from "../../../../Modal";
|
||||
import Modal from "../../../../Modal";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
|
||||
import DialogButtons from "../../../../components/views/elements/DialogButtons";
|
||||
@@ -28,8 +28,8 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent<IPr
|
||||
|
||||
private onSetupClick = (): void => {
|
||||
this.props.onFinished();
|
||||
Modal.createDialogAsync(
|
||||
import("./CreateKeyBackupDialog") as unknown as Promise<ComponentType>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("./CreateKeyBackupDialog")),
|
||||
undefined,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
|
||||
@@ -37,7 +37,7 @@ const USER_REGEX = /\B@\S*/g;
|
||||
|
||||
// used when you hit 'tab' - we allow some separator chars at the beginning
|
||||
// to allow you to tab-complete /mat into /(matthew)
|
||||
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
|
||||
const FORCED_USER_REGEX = /[^/,.():; \t\n]\S*/g;
|
||||
|
||||
export default class UserProvider extends AutocompleteProvider {
|
||||
public matcher: QueryMatcher<RoomMember>;
|
||||
|
||||
@@ -31,4 +31,3 @@ export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplie
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BackdropPanel;
|
||||
|
||||
@@ -23,7 +23,6 @@ import classNames from "classnames";
|
||||
import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard";
|
||||
import PageTypes from "../../PageTypes";
|
||||
import MediaDeviceHandler from "../../MediaDeviceHandler";
|
||||
import { fixupColorFonts } from "../../utils/FontManager";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { IMatrixClientCreds } from "../../MatrixClientPeg";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
@@ -49,11 +48,10 @@ import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandl
|
||||
import AudioFeedArrayForLegacyCall from "../views/voip/AudioFeedArrayForLegacyCall";
|
||||
import { OwnProfileStore } from "../../stores/OwnProfileStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import RoomView from "./RoomView";
|
||||
import type { RoomView as RoomViewType } from "./RoomView";
|
||||
import { RoomView } from "./RoomView";
|
||||
import ToastContainer from "./ToastContainer";
|
||||
import UserView from "./UserView";
|
||||
import BackdropPanel from "./BackdropPanel";
|
||||
import { BackdropPanel } from "./BackdropPanel";
|
||||
import { mediaFromMxc } from "../../customisations/Media";
|
||||
import { UserTab } from "../views/dialogs/UserTab";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
@@ -125,7 +123,7 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
public static displayName = "LoggedInView";
|
||||
|
||||
protected readonly _matrixClient: MatrixClient;
|
||||
protected readonly _roomView: React.RefObject<RoomViewType>;
|
||||
protected readonly _roomView: React.RefObject<RoomView>;
|
||||
protected readonly _resizeContainer: React.RefObject<HTMLDivElement>;
|
||||
protected readonly resizeHandler: React.RefObject<HTMLDivElement>;
|
||||
protected layoutWatcherRef?: string;
|
||||
@@ -150,8 +148,6 @@ class LoggedInView extends React.Component<IProps, IState> {
|
||||
|
||||
MediaDeviceHandler.loadDevices();
|
||||
|
||||
fixupColorFonts();
|
||||
|
||||
this._roomView = React.createRef();
|
||||
this._resizeContainer = React.createRef();
|
||||
this.resizeHandler = React.createRef();
|
||||
|
||||
@@ -6,7 +6,7 @@ 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, { createRef } from "react";
|
||||
import React, { createRef, lazy } from "react";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
@@ -28,8 +28,6 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
// what-input helps improve keyboard accessibility
|
||||
import "what-input";
|
||||
|
||||
import type NewRecoveryMethodDialog from "../../async-components/views/dialogs/security/NewRecoveryMethodDialog";
|
||||
import type RecoveryMethodRemovedDialog from "../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
import { IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
@@ -429,7 +427,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
} else if (
|
||||
(await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) &&
|
||||
!shouldSkipSetupEncryption(cli)
|
||||
!(await shouldSkipSetupEncryption(cli))
|
||||
) {
|
||||
// if cross-signing is not yet set up, do so now if possible.
|
||||
this.setStateForNewView({ view: Views.E2E_SETUP });
|
||||
@@ -1640,7 +1638,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
} else {
|
||||
// otherwise check the server to see if there's a new one
|
||||
try {
|
||||
newVersionInfo = await cli.getKeyBackupVersion();
|
||||
newVersionInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
|
||||
if (newVersionInfo !== null) haveNewVersion = true;
|
||||
} catch (e) {
|
||||
logger.error("Saw key backup error but failed to check backup version!", e);
|
||||
@@ -1649,16 +1647,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
if (haveNewVersion) {
|
||||
Modal.createDialogAsync(
|
||||
import(
|
||||
"../../async-components/views/dialogs/security/NewRecoveryMethodDialog"
|
||||
) as unknown as Promise<typeof NewRecoveryMethodDialog>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../async-components/views/dialogs/security/NewRecoveryMethodDialog")),
|
||||
);
|
||||
} else {
|
||||
Modal.createDialogAsync(
|
||||
import(
|
||||
"../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog"
|
||||
) as unknown as Promise<typeof RecoveryMethodRemovedDialog>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog")),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,16 @@ 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, { ChangeEvent, ComponentProps, createRef, ReactElement, ReactNode, RefObject, useContext } from "react";
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ComponentProps,
|
||||
createRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useContext,
|
||||
JSX,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
IRecommendedVersion,
|
||||
@@ -29,6 +38,7 @@ import {
|
||||
MatrixError,
|
||||
ISearchResults,
|
||||
THREAD_RELATION_TYPE,
|
||||
MatrixClient,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -45,7 +55,7 @@ import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import ContentMessages from "../../ContentMessages";
|
||||
import Modal from "../../Modal";
|
||||
import { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
import dis, { defaultDispatcher } from "../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import * as Rooms from "../../Rooms";
|
||||
import MainSplit from "./MainSplit";
|
||||
import RightPanel from "./RightPanel";
|
||||
@@ -233,6 +243,11 @@ export interface IRoomState {
|
||||
liveTimeline?: EventTimeline;
|
||||
narrow: boolean;
|
||||
msc3946ProcessDynamicPredecessor: boolean;
|
||||
/**
|
||||
* Whether the room is encrypted or not.
|
||||
* If null, we are still determining the encryption status.
|
||||
*/
|
||||
isRoomEncrypted: boolean | null;
|
||||
|
||||
canAskToJoin: boolean;
|
||||
promptAskToJoin: boolean;
|
||||
@@ -417,6 +432,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
canAskToJoin: this.askToJoinEnabled,
|
||||
promptAskToJoin: false,
|
||||
viewRoomOpts: { buttons: [] },
|
||||
isRoomEncrypted: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -437,7 +453,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
private onWidgetLayoutChange = (): void => {
|
||||
if (!this.state.room) return;
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "appsDrawer",
|
||||
show: true,
|
||||
});
|
||||
@@ -598,7 +614,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// Handle the use case of a link to a thread message
|
||||
// ie: #/room/roomId/eventId (eventId of a thread message)
|
||||
if (thread?.rootEvent && !initialEvent?.isThreadRoot) {
|
||||
dis.dispatch<ShowThreadPayload>({
|
||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: thread.rootEvent,
|
||||
initialEvent,
|
||||
@@ -704,7 +720,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
const activeCall = CallStore.instance.getActiveCall(this.state.roomId);
|
||||
if (activeCall === null) {
|
||||
// We disconnected from the call, so stop viewing it
|
||||
dis.dispatch<ViewRoomPayload>(
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>(
|
||||
{
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.state.roomId,
|
||||
@@ -847,10 +863,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
return isManuallyShown && widgets.length > 0;
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
public async componentDidMount(): Promise<void> {
|
||||
this.unmounted = false;
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
if (this.context.client) {
|
||||
this.context.client.on(ClientEvent.Room, this.onRoom);
|
||||
this.context.client.on(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
@@ -967,7 +983,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// stop tracking room changes to format permalinks
|
||||
this.stopAllPermalinkCreators();
|
||||
|
||||
dis.unregister(this.dispatcherRef);
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
if (this.context.client) {
|
||||
this.context.client.removeListener(ClientEvent.Room, this.onRoom);
|
||||
this.context.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
|
||||
@@ -1045,7 +1061,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
handled = true;
|
||||
break;
|
||||
case KeyBindingAction.UploadFile: {
|
||||
dis.dispatch(
|
||||
defaultDispatcher.dispatch(
|
||||
{
|
||||
action: "upload_file",
|
||||
context: TimelineRenderingType.Room,
|
||||
@@ -1145,7 +1161,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
if (payload.event && payload.event.getRoomId() !== this.state.roomId) {
|
||||
// If the event is in a different room (e.g. because the event to be edited is being displayed
|
||||
// in the results of an all-rooms search), we need to view that room first.
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: payload.event.getRoomId(),
|
||||
metricsTrigger: undefined,
|
||||
@@ -1188,7 +1204,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
}
|
||||
|
||||
// re-dispatch to the correct composer
|
||||
dis.dispatch<ComposerInsertPayload>({
|
||||
defaultDispatcher.dispatch<ComposerInsertPayload>({
|
||||
...(payload as ComposerInsertPayload),
|
||||
timelineRenderingType,
|
||||
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
|
||||
@@ -1197,7 +1213,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
}
|
||||
|
||||
case Action.FocusAComposer: {
|
||||
dis.dispatch<FocusComposerPayload>({
|
||||
defaultDispatcher.dispatch<FocusComposerPayload>({
|
||||
...(payload as FocusComposerPayload),
|
||||
// re-dispatch to the correct composer (the send message will still be on screen even when editing a message)
|
||||
action: this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer,
|
||||
@@ -1303,7 +1319,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) {
|
||||
// For initial threads launch, chat effects are disabled see #19731
|
||||
if (!ev.isRelation(THREAD_RELATION_TYPE.name)) {
|
||||
dis.dispatch({ action: `effects.${effect.command}`, event: ev });
|
||||
defaultDispatcher.dispatch({ action: `effects.${effect.command}`, event: ev });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1342,13 +1358,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
|
||||
|
||||
this.calculatePeekRules(room);
|
||||
this.updatePreviewUrlVisibility(room);
|
||||
this.loadMembersIfJoined(room);
|
||||
this.calculateRecommendedVersion(room);
|
||||
this.updateE2EStatus(room);
|
||||
this.updatePermissions(room);
|
||||
this.checkWidgets(room);
|
||||
this.loadVirtualRoom(room);
|
||||
this.updateRoomEncrypted(room);
|
||||
|
||||
if (
|
||||
this.getMainSplitContentType(room) !== MainSplitContentType.Timeline &&
|
||||
@@ -1363,7 +1378,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
liveTimeline: room.getLiveTimeline(),
|
||||
});
|
||||
|
||||
dis.dispatch<ActionPayload>({ action: Action.RoomLoaded });
|
||||
defaultDispatcher.dispatch<ActionPayload>({ action: Action.RoomLoaded });
|
||||
};
|
||||
|
||||
private onRoomTimelineReset = (room?: Room): void => {
|
||||
@@ -1377,6 +1392,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
return room?.currentState.getStateEvents(EventType.RoomTombstone, "") ?? undefined;
|
||||
}
|
||||
|
||||
private async getIsRoomEncrypted(roomId = this.state.roomId): Promise<boolean> {
|
||||
const crypto = this.context.client?.getCrypto();
|
||||
if (!crypto || !roomId) return false;
|
||||
|
||||
return await crypto.isEncryptionEnabledInRoom(roomId);
|
||||
}
|
||||
|
||||
private async calculateRecommendedVersion(room: Room): Promise<void> {
|
||||
const upgradeRecommendation = await room.getRecommendedVersion();
|
||||
if (this.unmounted) return;
|
||||
@@ -1409,12 +1431,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
private updatePreviewUrlVisibility({ roomId }: Room): void {
|
||||
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
|
||||
const key = this.context.client?.isRoomEncrypted(roomId) ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled";
|
||||
this.setState({
|
||||
showUrlPreview: SettingsStore.getValue(key, roomId),
|
||||
});
|
||||
private updatePreviewUrlVisibility(room: Room): void {
|
||||
this.setState(({ isRoomEncrypted }) => ({
|
||||
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
|
||||
}));
|
||||
}
|
||||
|
||||
private getPreviewUrlVisibility({ roomId }: Room, isRoomEncrypted: boolean | null): boolean {
|
||||
const key = isRoomEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled";
|
||||
return SettingsStore.getValue(key, roomId);
|
||||
}
|
||||
|
||||
private onRoom = (room: Room): void => {
|
||||
@@ -1456,7 +1481,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
};
|
||||
|
||||
private async updateE2EStatus(room: Room): Promise<void> {
|
||||
if (!this.context.client?.isRoomEncrypted(room.roomId)) return;
|
||||
if (!this.context.client || !this.state.isRoomEncrypted) return;
|
||||
|
||||
// If crypto is not currently enabled, we aren't tracking devices at all,
|
||||
// so we don't know what the answer is. Let's error on the safe side and show
|
||||
@@ -1467,33 +1492,54 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
if (this.context.client.getCrypto()) {
|
||||
/* At this point, the user has encryption on and cross-signing on */
|
||||
e2eStatus = await shieldStatusForRoom(this.context.client, room);
|
||||
RoomView.e2eStatusCache.set(room.roomId, e2eStatus);
|
||||
e2eStatus = await this.cacheAndGetE2EStatus(room, this.context.client);
|
||||
if (this.unmounted) return;
|
||||
this.setState({ e2eStatus });
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheAndGetE2EStatus(room: Room, client: MatrixClient): Promise<E2EStatus> {
|
||||
const e2eStatus = await shieldStatusForRoom(client, room);
|
||||
RoomView.e2eStatusCache.set(room.roomId, e2eStatus);
|
||||
return e2eStatus;
|
||||
}
|
||||
|
||||
private onUrlPreviewsEnabledChange = (): void => {
|
||||
if (this.state.room) {
|
||||
this.updatePreviewUrlVisibility(this.state.room);
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent, state: RoomState): void => {
|
||||
private onRoomStateEvents = async (ev: MatrixEvent, state: RoomState): Promise<void> => {
|
||||
// ignore if we don't have a room yet
|
||||
if (!this.state.room || this.state.room.roomId !== state.roomId) return;
|
||||
if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return;
|
||||
|
||||
switch (ev.getType()) {
|
||||
case EventType.RoomTombstone:
|
||||
this.setState({ tombstone: this.getRoomTombstone() });
|
||||
break;
|
||||
|
||||
case EventType.RoomEncryption: {
|
||||
await this.updateRoomEncrypted();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.updatePermissions(this.state.room);
|
||||
}
|
||||
};
|
||||
|
||||
private async updateRoomEncrypted(room = this.state.room): Promise<void> {
|
||||
if (!room || !this.context.client) return;
|
||||
|
||||
const isRoomEncrypted = await this.getIsRoomEncrypted(room.roomId);
|
||||
const newE2EStatus = isRoomEncrypted ? await this.cacheAndGetE2EStatus(room, this.context.client) : null;
|
||||
|
||||
this.setState({
|
||||
isRoomEncrypted,
|
||||
showUrlPreview: this.getPreviewUrlVisibility(room, isRoomEncrypted),
|
||||
...(newE2EStatus && { e2eStatus: newE2EStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
private onRoomStateUpdate = (state: RoomState): void => {
|
||||
// ignore members in other rooms
|
||||
if (state.roomId !== this.state.room?.roomId) {
|
||||
@@ -1561,7 +1607,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
private onInviteClick = (): void => {
|
||||
// open the room inviter
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_invite",
|
||||
roomId: this.getRoomId(),
|
||||
});
|
||||
@@ -1572,7 +1618,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
if (this.context.client?.isGuest()) {
|
||||
// Join this room once the user has registered and logged in
|
||||
// (If we failed to peek, we may not have a valid room object.)
|
||||
dis.dispatch<DoAfterSyncPreparedPayload<ViewRoomPayload>>({
|
||||
defaultDispatcher.dispatch<DoAfterSyncPreparedPayload<ViewRoomPayload>>({
|
||||
action: Action.DoAfterSyncPrepared,
|
||||
deferred_action: {
|
||||
action: Action.ViewRoom,
|
||||
@@ -1580,13 +1626,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
metricsTrigger: undefined,
|
||||
},
|
||||
});
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
defaultDispatcher.dispatch({ action: "require_registration" });
|
||||
} else {
|
||||
Promise.resolve().then(() => {
|
||||
const signUrl = this.props.threepidInvite?.signUrl;
|
||||
const roomId = this.getRoomId();
|
||||
if (isNotUndefined(roomId)) {
|
||||
dis.dispatch<JoinRoomPayload>({
|
||||
defaultDispatcher.dispatch<JoinRoomPayload>({
|
||||
action: Action.JoinRoom,
|
||||
roomId,
|
||||
opts: { inviteSignUrl: signUrl },
|
||||
@@ -1622,7 +1668,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
this.state.initialEventId === eventId
|
||||
) {
|
||||
debuglog("Removing scroll_into_view flag from initial event");
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.getRoomId(),
|
||||
event_id: this.state.initialEventId,
|
||||
@@ -1638,7 +1684,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
const roomId = this.getRoomId();
|
||||
if (!this.context.client || !roomId) return;
|
||||
if (this.context.client.isGuest()) {
|
||||
dis.dispatch({ action: "require_registration" });
|
||||
defaultDispatcher.dispatch({ action: "require_registration" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1688,7 +1734,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
};
|
||||
|
||||
private onForgetClick = (): void => {
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "forget_room",
|
||||
room_id: this.getRoomId(),
|
||||
});
|
||||
@@ -1702,7 +1748,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
this.context.client?.leave(roomId).then(
|
||||
() => {
|
||||
dis.dispatch({ action: Action.ViewHomePage });
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
@@ -1736,7 +1782,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
await this.context.client!.setIgnoredUsers(ignoredUsers);
|
||||
|
||||
await this.context.client!.leave(this.state.roomId!);
|
||||
dis.dispatch({ action: Action.ViewHomePage });
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
@@ -1760,7 +1806,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
// https://github.com/vector-im/vector-web/issues/1134
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
defaultDispatcher.fire(Action.ViewRoomDirectory);
|
||||
};
|
||||
|
||||
private onSearchChange = debounce((e: ChangeEvent): void => {
|
||||
@@ -1786,7 +1832,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
// If we were viewing a highlighted event, firing view_room without
|
||||
// an event will take care of both clearing the URL fragment and
|
||||
// jumping to the bottom
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
@@ -1794,7 +1840,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
} else {
|
||||
// Otherwise we have to jump manually
|
||||
this.messagePanel?.jumpToLiveTimeline();
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1918,7 +1964,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
public onHiddenHighlightsClick = (): void => {
|
||||
const oldRoom = this.getOldRoom();
|
||||
if (!oldRoom) return;
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: oldRoom.roomId,
|
||||
metricsTrigger: "Predecessor",
|
||||
@@ -2001,7 +2047,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
const roomId = this.getRoomId();
|
||||
|
||||
if (isNotUndefined(roomId)) {
|
||||
dis.dispatch<SubmitAskToJoinPayload>({
|
||||
defaultDispatcher.dispatch<SubmitAskToJoinPayload>({
|
||||
action: Action.SubmitAskToJoin,
|
||||
roomId,
|
||||
opts: { reason },
|
||||
@@ -2018,7 +2064,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
const roomId = this.getRoomId();
|
||||
|
||||
if (isNotUndefined(roomId)) {
|
||||
dis.dispatch<CancelAskToJoinPayload>({
|
||||
defaultDispatcher.dispatch<CancelAskToJoinPayload>({
|
||||
action: Action.CancelAskToJoin,
|
||||
roomId,
|
||||
});
|
||||
@@ -2027,6 +2073,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
public render(): ReactNode {
|
||||
if (!this.context.client) return null;
|
||||
const { isRoomEncrypted } = this.state;
|
||||
const isRoomEncryptionLoading = isRoomEncrypted === null;
|
||||
|
||||
if (this.state.room instanceof LocalRoom) {
|
||||
if (this.state.room.state === LocalRoomState.CREATING) {
|
||||
@@ -2242,14 +2290,16 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
let aux: JSX.Element | undefined;
|
||||
let previewBar;
|
||||
if (this.state.timelineRenderingType === TimelineRenderingType.Search) {
|
||||
aux = (
|
||||
<RoomSearchAuxPanel
|
||||
searchInfo={this.state.search}
|
||||
onCancelClick={this.onCancelSearchClick}
|
||||
onSearchScopeChange={this.onSearchScopeChange}
|
||||
isRoomEncrypted={this.context.client.isRoomEncrypted(this.state.room.roomId)}
|
||||
/>
|
||||
);
|
||||
if (!isRoomEncryptionLoading) {
|
||||
aux = (
|
||||
<RoomSearchAuxPanel
|
||||
searchInfo={this.state.search}
|
||||
onCancelClick={this.onCancelSearchClick}
|
||||
onSearchScopeChange={this.onSearchScopeChange}
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (showRoomUpgradeBar) {
|
||||
aux = <RoomUpgradeWarningBar room={this.state.room} />;
|
||||
} else if (myMembership !== KnownMembership.Join) {
|
||||
@@ -2325,8 +2375,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
|
||||
let messageComposer;
|
||||
const showComposer =
|
||||
!isRoomEncryptionLoading &&
|
||||
// joined and not showing search results
|
||||
myMembership === KnownMembership.Join && !this.state.search;
|
||||
myMembership === KnownMembership.Join &&
|
||||
!this.state.search;
|
||||
if (showComposer) {
|
||||
messageComposer = (
|
||||
<MessageComposer
|
||||
@@ -2367,34 +2419,37 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
highlightedEventId = this.state.initialEventId;
|
||||
}
|
||||
|
||||
const messagePanel = (
|
||||
<TimelinePanel
|
||||
ref={this.gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSetFilter={isCallEvent}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
|
||||
manageReadMarkers={!this.state.isPeeking}
|
||||
hidden={hideMessagePanel}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventId={this.state.initialEventId}
|
||||
eventScrollIntoView={this.state.initialEventScrollIntoView}
|
||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={this.messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showReactions={true}
|
||||
layout={this.state.layout}
|
||||
editState={this.state.editState}
|
||||
/>
|
||||
);
|
||||
let messagePanel: JSX.Element | undefined;
|
||||
if (!isRoomEncryptionLoading) {
|
||||
messagePanel = (
|
||||
<TimelinePanel
|
||||
ref={this.gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSetFilter={isCallEvent}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
|
||||
manageReadMarkers={!this.state.isPeeking}
|
||||
hidden={hideMessagePanel}
|
||||
highlightedEventId={highlightedEventId}
|
||||
eventId={this.state.initialEventId}
|
||||
eventScrollIntoView={this.state.initialEventScrollIntoView}
|
||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||
onScroll={this.onMessageListScroll}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
|
||||
showUrlPreview={this.state.showUrlPreview}
|
||||
className={this.messagePanelClassNames}
|
||||
membersLoaded={this.state.membersLoaded}
|
||||
permalinkCreator={this.permalinkCreator}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
showReactions={true}
|
||||
layout={this.state.layout}
|
||||
editState={this.state.editState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let topUnreadMessagesBar: JSX.Element | undefined;
|
||||
// Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense
|
||||
@@ -2415,7 +2470,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
);
|
||||
}
|
||||
|
||||
const showRightPanel = this.state.room && this.state.showRightPanel;
|
||||
const showRightPanel = !isRoomEncryptionLoading && this.state.room && this.state.showRightPanel;
|
||||
|
||||
const rightPanel = showRightPanel ? (
|
||||
<RightPanel
|
||||
@@ -2547,5 +2602,3 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RoomView;
|
||||
|
||||
@@ -114,7 +114,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||
(room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room"));
|
||||
|
||||
const [showChildren, toggleShowChildren] = useStateToggle(true);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onPreviewClick = (ev: ButtonEvent): void => {
|
||||
@@ -288,7 +288,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ref.current?.focus();
|
||||
nodeRef.current?.focus();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -315,7 +315,7 @@ const Tile: React.FC<ITileProps> = ({
|
||||
case KeyBindingAction.ArrowRight:
|
||||
handled = true;
|
||||
if (showChildren) {
|
||||
const childSection = ref.current?.nextElementSibling;
|
||||
const childSection = nodeRef.current?.nextElementSibling;
|
||||
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
|
||||
} else {
|
||||
toggleShowChildren();
|
||||
@@ -790,7 +790,7 @@ const SpaceHierarchy: React.FC<IProps> = ({ space, initialText = "", showRoom, a
|
||||
const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
|
||||
state.refs[0]?.current?.focus();
|
||||
state.nodes[0]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1217,7 +1217,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
return;
|
||||
}
|
||||
const lastDisplayedEvent = this.state.events[lastDisplayedIndex];
|
||||
this.setReadMarker(lastDisplayedEvent.getId()!, lastDisplayedEvent.getTs());
|
||||
await this.setReadMarker(lastDisplayedEvent.getId()!, lastDisplayedEvent.getTs());
|
||||
|
||||
// the read-marker should become invisible, so that if the user scrolls
|
||||
// down, they don't see it.
|
||||
@@ -1335,7 +1335,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// Update the read marker to the values we found
|
||||
this.setReadMarker(rmId, rmTs);
|
||||
await this.setReadMarker(rmId, rmTs);
|
||||
|
||||
// Send the receipts to the server immediately (don't wait for activity)
|
||||
await this.sendReadReceipts();
|
||||
@@ -1866,7 +1866,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
return receiptStore?.getEventReadUpTo(myUserId, ignoreSynthesized) ?? null;
|
||||
}
|
||||
|
||||
private setReadMarker(eventId: string | null, eventTs?: number, inhibitSetState = false): void {
|
||||
private async setReadMarker(eventId: string | null, eventTs?: number, inhibitSetState = false): Promise<void> {
|
||||
const roomId = this.props.timelineSet.room?.roomId;
|
||||
|
||||
// don't update the state (and cause a re-render) if there is
|
||||
@@ -1890,12 +1890,17 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// Do the local echo of the RM
|
||||
// run the render cycle before calling the callback, so that
|
||||
// getReadMarkerPosition() returns the right thing.
|
||||
this.setState(
|
||||
{
|
||||
readMarkerEventId: eventId,
|
||||
},
|
||||
this.props.onReadMarkerUpdated,
|
||||
);
|
||||
await new Promise<void>((resolve) => {
|
||||
this.setState(
|
||||
{
|
||||
readMarkerEventId: eventId,
|
||||
},
|
||||
() => {
|
||||
this.props.onReadMarkerUpdated?.();
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private shouldPaginate(): boolean {
|
||||
|
||||
@@ -75,6 +75,7 @@ interface State {
|
||||
}
|
||||
|
||||
export default class ForgotPassword extends React.Component<Props, State> {
|
||||
private unmounted = false;
|
||||
private reset: PasswordReset;
|
||||
private fieldPassword: Field | null = null;
|
||||
private fieldPasswordConfirm: Field | null = null;
|
||||
@@ -108,14 +109,20 @@ export default class ForgotPassword extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<void> {
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<boolean> {
|
||||
try {
|
||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(serverConfig.hsUrl, serverConfig.isUrl);
|
||||
if (this.unmounted) return false;
|
||||
|
||||
this.setState({
|
||||
serverIsAlive: true,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (this.unmounted) return false;
|
||||
const { serverIsAlive, serverDeadError } = AutoDiscoveryUtils.authComponentStateForError(
|
||||
e,
|
||||
"forgot_password",
|
||||
@@ -124,7 +131,9 @@ export default class ForgotPassword extends React.Component<Props, State> {
|
||||
serverIsAlive,
|
||||
errorText: serverDeadError,
|
||||
});
|
||||
return serverIsAlive;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async onPhaseEmailInputSubmit(): Promise<void> {
|
||||
@@ -292,10 +301,10 @@ export default class ForgotPassword extends React.Component<Props, State> {
|
||||
});
|
||||
|
||||
// Refresh the server errors. Just in case the server came back online of went offline.
|
||||
await this.checkServerLiveliness(this.props.serverConfig);
|
||||
const serverIsAlive = await this.checkServerLiveliness(this.props.serverConfig);
|
||||
|
||||
// Server error
|
||||
if (!this.state.serverIsAlive) return;
|
||||
if (!serverIsAlive) return;
|
||||
|
||||
switch (this.state.phase) {
|
||||
case Phase.EnterEmail:
|
||||
|
||||
@@ -16,7 +16,7 @@ const AuthFooter = (): ReactElement => {
|
||||
const brandingConfig = SdkConfig.getObject("branding");
|
||||
const links = brandingConfig?.get("auth_footer_links") ?? [
|
||||
{ text: "Blog", url: "https://element.io/blog" },
|
||||
{ text: "Twitter", url: "https://twitter.com/element_hq" },
|
||||
{ text: "Mastodon", url: "https://mastodon.matrix.org/@Element" },
|
||||
{ text: "GitHub", url: "https://github.com/element-hq/element-web" },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { ConnectionState, ElementCall } from "../../../models/Call";
|
||||
@@ -53,7 +53,7 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({ roomId, call }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: roomId,
|
||||
metricsTrigger: undefined,
|
||||
|
||||
@@ -256,7 +256,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
||||
});
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const isVideoRoom = this.props.type === RoomType.ElementVideo;
|
||||
const isVideoRoom = this.props.type === RoomType.ElementVideo || this.props.type === RoomType.UnstableCall;
|
||||
|
||||
let aliasField: JSX.Element | undefined;
|
||||
if (this.state.joinRule === JoinRule.Public) {
|
||||
|
||||
@@ -294,7 +294,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
switch (action) {
|
||||
case KeyBindingAction.Enter: {
|
||||
state.activeRef?.current?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
|
||||
state.activeNode?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -347,13 +347,13 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
onSearch={(query: string): void => {
|
||||
setQuery(query);
|
||||
setTimeout(() => {
|
||||
const ref = context.state.refs[0];
|
||||
if (ref) {
|
||||
const node = context.state.nodes[0];
|
||||
if (node) {
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
payload: { node },
|
||||
});
|
||||
ref.current?.scrollIntoView?.({
|
||||
node?.scrollIntoView?.({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
@@ -361,7 +361,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
}}
|
||||
autoFocus={true}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
aria-activedescendant={context.state.activeRef?.current?.id}
|
||||
aria-activedescendant={context.state.activeNode?.id}
|
||||
aria-owns="mx_ForwardDialog_resultsList"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,12 +7,10 @@ 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 from "react";
|
||||
import React, { lazy } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
|
||||
import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog";
|
||||
import Modal from "../../../Modal";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
@@ -111,15 +109,13 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
// backup is not active. see if there is a backup version on the server we ought to back up to.
|
||||
const backupInfo = await client.getKeyBackupVersion();
|
||||
const backupInfo = await crypto.getKeyBackupInfo();
|
||||
this.setState({ backupStatus: backupInfo ? BackupStatus.SERVER_BACKUP_BUT_DISABLED : BackupStatus.NO_BACKUP });
|
||||
}
|
||||
|
||||
private onExportE2eKeysClicked = (): void => {
|
||||
Modal.createDialogAsync(
|
||||
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise<
|
||||
typeof ExportE2eKeysDialog
|
||||
>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
|
||||
{
|
||||
matrixClient: MatrixClientPeg.safeGet(),
|
||||
},
|
||||
@@ -147,10 +143,8 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
|
||||
/* static = */ true,
|
||||
);
|
||||
} else {
|
||||
Modal.createDialogAsync(
|
||||
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise<
|
||||
typeof CreateKeyBackupDialog
|
||||
>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
|
||||
undefined,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
|
||||
@@ -43,6 +43,10 @@ interface IState {
|
||||
// If we know it, the nature of the abuse, as specified by MSC3215.
|
||||
nature?: ExtendedNature;
|
||||
ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit
|
||||
/*
|
||||
* Whether the room is encrypted.
|
||||
*/
|
||||
isRoomEncrypted: boolean;
|
||||
}
|
||||
|
||||
const MODERATED_BY_STATE_EVENT_TYPE = [
|
||||
@@ -188,9 +192,20 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
|
||||
// If specified, the nature of the abuse, as specified by MSC3215.
|
||||
nature: undefined,
|
||||
ignoreUserToo: false, // default false, for now. Could easily be argued as default true
|
||||
isRoomEncrypted: false, // async, will be set later
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount = async (): Promise<void> => {
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
const roomId = this.props.mxEvent.getRoomId();
|
||||
if (!crypto || !roomId) return;
|
||||
|
||||
this.setState({
|
||||
isRoomEncrypted: await crypto.isEncryptionEnabledInRoom(roomId),
|
||||
});
|
||||
};
|
||||
|
||||
private onIgnoreUserTooChanged = (newVal: boolean): void => {
|
||||
this.setState({ ignoreUserToo: newVal });
|
||||
};
|
||||
@@ -319,7 +334,6 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
|
||||
if (this.moderation) {
|
||||
// Display report-to-moderator dialog.
|
||||
// We let the user pick a nature.
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const homeServerName = SdkConfig.get("validated_server_config")!.hsName;
|
||||
let subtitle: string;
|
||||
switch (this.state.nature) {
|
||||
@@ -336,7 +350,7 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
|
||||
subtitle = _t("report_content|nature_spam");
|
||||
break;
|
||||
case NonStandardValue.Admin:
|
||||
if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) {
|
||||
if (this.state.isRoomEncrypted) {
|
||||
subtitle = _t("report_content|nature_nonstandard_admin_encrypted", {
|
||||
homeserver: homeServerName,
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SpacePreferenceTab } from "../../../dispatcher/payloads/OpenSpacePrefer
|
||||
import { NonEmptyArray } from "../../../@types/common";
|
||||
import SettingsTab from "../settings/tabs/SettingsTab";
|
||||
import { SettingsSection } from "../settings/shared/SettingsSection";
|
||||
import SettingsSubsection, { SettingsSubsectionText } from "../settings/shared/SettingsSubsection";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "../settings/shared/SettingsSubsection";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { FilesIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Icon as FileIcon } from "../../../../res/img/feather-customised/files.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
@@ -86,7 +86,7 @@ export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
placeholder = <FileIcon className="mx_UploadConfirmDialog_fileIcon" height={18} width={18} />;
|
||||
placeholder = <FilesIcon className="mx_UploadConfirmDialog_fileIcon" height="18px" width="18px" />;
|
||||
}
|
||||
|
||||
let uploadAllButton: JSX.Element | undefined;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { determineUnreadState } from "../../../../RoomNotifs";
|
||||
import { humanReadableNotificationLevel } from "../../../../stores/notifications/NotificationLevel";
|
||||
import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread";
|
||||
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
|
||||
import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts";
|
||||
|
||||
function UserReadUpTo({ target }: { target: ReadReceipt<any, any> }): JSX.Element {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
@@ -59,6 +60,7 @@ function UserReadUpTo({ target }: { target: ReadReceipt<any, any> }): JSX.Elemen
|
||||
export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element {
|
||||
const { room } = useContext(DevtoolsContext);
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const isRoomEncrypted = useIsEncrypted(cli, room);
|
||||
|
||||
const { level, count } = determineUnreadState(room, undefined, false);
|
||||
const [notificationState] = useNotificationState(room);
|
||||
@@ -93,9 +95,7 @@ export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Eleme
|
||||
</li>
|
||||
<li>
|
||||
{_t(
|
||||
cli.isRoomEncrypted(room.roomId!)
|
||||
? _td("devtools|room_encrypted")
|
||||
: _td("devtools|room_not_encrypted"),
|
||||
isRoomEncrypted ? _td("devtools|room_encrypted") : _td("devtools|room_not_encrypted"),
|
||||
{},
|
||||
{
|
||||
strong: (sub) => <strong>{sub}</strong>,
|
||||
|
||||
@@ -19,7 +19,6 @@ import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { accessSecretStorage } from "../../../../SecurityManager";
|
||||
import Modal from "../../../../Modal";
|
||||
import InteractiveAuthDialog from "../InteractiveAuthDialog";
|
||||
import DialogButtons from "../../elements/DialogButtons";
|
||||
import BaseDialog from "../BaseDialog";
|
||||
import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds";
|
||||
@@ -226,28 +225,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
|
||||
|
||||
try {
|
||||
// Force reset secret storage (which resets the key backup)
|
||||
await accessSecretStorage(async (): Promise<void> => {
|
||||
// Now reset cross-signing so everything Just Works™ again.
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
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,
|
||||
});
|
||||
|
||||
// Now we can indicate that the user is done pressing buttons, finally.
|
||||
// Upstream flows will detect the new secret storage, key backup, etc and use it.
|
||||
this.props.onFinished({});
|
||||
}, true);
|
||||
await accessSecretStorage(
|
||||
async (): Promise<void> => {
|
||||
// Now we can indicate that the user is done pressing buttons, finally.
|
||||
// Upstream flows will detect the new secret storage, key backup, etc and use it.
|
||||
this.props.onFinished({});
|
||||
},
|
||||
{ forceReset: true, resetCrossSigning: true },
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
this.props.onFinished(false);
|
||||
|
||||
@@ -8,9 +8,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent } from "react";
|
||||
import { MatrixClient, MatrixError, SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
import { decodeRecoveryKey, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup";
|
||||
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { decodeRecoveryKey, KeyBackupInfo, KeyBackupRestoreResult } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
@@ -42,12 +41,11 @@ interface IProps {
|
||||
|
||||
interface IState {
|
||||
backupInfo: KeyBackupInfo | null;
|
||||
backupKeyStored: Record<string, SecretStorage.SecretStorageKeyDescription> | null;
|
||||
loading: boolean;
|
||||
loadError: boolean | null;
|
||||
restoreError: unknown | null;
|
||||
recoveryKey: string;
|
||||
recoverInfo: IKeyBackupRestoreResult | null;
|
||||
recoverInfo: KeyBackupRestoreResult | null;
|
||||
recoveryKeyValid: boolean;
|
||||
forceRecoveryKey: boolean;
|
||||
passPhrase: string;
|
||||
@@ -72,7 +70,6 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
super(props);
|
||||
this.state = {
|
||||
backupInfo: null,
|
||||
backupKeyStored: null,
|
||||
loading: false,
|
||||
loadError: null,
|
||||
restoreError: null,
|
||||
@@ -112,7 +109,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
|
||||
private onResetRecoveryClick = (): void => {
|
||||
this.props.onFinished(false);
|
||||
accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
|
||||
accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,7 +134,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
};
|
||||
|
||||
private onPassPhraseNext = async (): Promise<void> => {
|
||||
if (!this.state.backupInfo) return;
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
if (!crypto) return;
|
||||
this.setState({
|
||||
loading: true,
|
||||
restoreError: null,
|
||||
@@ -146,13 +144,9 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
try {
|
||||
// We do still restore the key backup: we must ensure that the key backup key
|
||||
// is the right one and restoring it is currently the only way we can do this.
|
||||
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithPassword(
|
||||
this.state.passPhrase,
|
||||
undefined,
|
||||
undefined,
|
||||
this.state.backupInfo,
|
||||
{ progressCallback: this.progressCallback },
|
||||
);
|
||||
const recoverInfo = await crypto.restoreKeyBackupWithPassphrase(this.state.passPhrase, {
|
||||
progressCallback: this.progressCallback,
|
||||
});
|
||||
|
||||
if (!this.props.showSummary) {
|
||||
this.props.onFinished(true);
|
||||
@@ -172,7 +166,8 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
};
|
||||
|
||||
private onRecoveryKeyNext = async (): Promise<void> => {
|
||||
if (!this.state.recoveryKeyValid || !this.state.backupInfo) return;
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
if (!this.state.recoveryKeyValid || !this.state.backupInfo?.version || !crypto) return;
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
@@ -180,13 +175,14 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
restoreType: RestoreType.RecoveryKey,
|
||||
});
|
||||
try {
|
||||
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithRecoveryKey(
|
||||
this.state.recoveryKey,
|
||||
undefined,
|
||||
undefined,
|
||||
this.state.backupInfo,
|
||||
{ progressCallback: this.progressCallback },
|
||||
await crypto.storeSessionBackupPrivateKey(
|
||||
decodeRecoveryKey(this.state.recoveryKey),
|
||||
this.state.backupInfo.version,
|
||||
);
|
||||
const recoverInfo = await crypto.restoreKeyBackup({
|
||||
progressCallback: this.progressCallback,
|
||||
});
|
||||
|
||||
if (!this.props.showSummary) {
|
||||
this.props.onFinished(true);
|
||||
return;
|
||||
@@ -210,44 +206,41 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
});
|
||||
};
|
||||
|
||||
private async restoreWithSecretStorage(): Promise<void> {
|
||||
private async restoreWithSecretStorage(): Promise<boolean> {
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
if (!crypto) return false;
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
restoreError: null,
|
||||
restoreType: RestoreType.SecretStorage,
|
||||
});
|
||||
try {
|
||||
let recoverInfo: KeyBackupRestoreResult | null = null;
|
||||
// `accessSecretStorage` may prompt for storage access as needed.
|
||||
await accessSecretStorage(async (): Promise<void> => {
|
||||
if (!this.state.backupInfo) return;
|
||||
await MatrixClientPeg.safeGet().restoreKeyBackupWithSecretStorage(
|
||||
this.state.backupInfo,
|
||||
undefined,
|
||||
undefined,
|
||||
{ progressCallback: this.progressCallback },
|
||||
);
|
||||
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
|
||||
recoverInfo = await crypto.restoreKeyBackup({ progressCallback: this.progressCallback });
|
||||
});
|
||||
this.setState({
|
||||
loading: false,
|
||||
recoverInfo,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.log("Error restoring backup", e);
|
||||
logger.log("restoreWithSecretStorage failed:", e);
|
||||
this.setState({
|
||||
restoreError: e,
|
||||
loading: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreWithCachedKey(backupInfo: KeyBackupInfo | null): Promise<boolean> {
|
||||
if (!backupInfo) return false;
|
||||
const crypto = MatrixClientPeg.safeGet().getCrypto();
|
||||
if (!crypto) return false;
|
||||
try {
|
||||
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache(
|
||||
undefined /* targetRoomId */,
|
||||
undefined /* targetSessionId */,
|
||||
backupInfo,
|
||||
{ progressCallback: this.progressCallback },
|
||||
);
|
||||
const recoverInfo = await crypto.restoreKeyBackup({ progressCallback: this.progressCallback });
|
||||
this.setState({
|
||||
recoverInfo,
|
||||
});
|
||||
@@ -265,12 +258,11 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
});
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const backupInfo = await cli.getKeyBackupVersion();
|
||||
const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
|
||||
const has4S = await cli.secretStorage.hasKey();
|
||||
const backupKeyStored = has4S ? await cli.isKeyBackupKeyStored() : null;
|
||||
this.setState({
|
||||
backupInfo,
|
||||
backupKeyStored,
|
||||
});
|
||||
|
||||
const gotCache = await this.restoreWithCachedKey(backupInfo);
|
||||
@@ -282,9 +274,13 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
return;
|
||||
}
|
||||
|
||||
// If the backup key is stored, we can proceed directly to restore.
|
||||
if (backupKeyStored) {
|
||||
return this.restoreWithSecretStorage();
|
||||
const hasBackupFromSS = backupKeyStored && (await this.restoreWithSecretStorage());
|
||||
if (hasBackupFromSS) {
|
||||
logger.log("RestoreKeyBackupDialog: found backup key in secret storage");
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
@@ -398,6 +394,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent<IProps,
|
||||
|
||||
<form className="mx_RestoreKeyBackupDialog_primaryContainer">
|
||||
<input
|
||||
data-testid="passphraseInput"
|
||||
type="password"
|
||||
className="mx_RestoreKeyBackupDialog_passPhraseInput"
|
||||
onChange={this.onPassPhraseChange}
|
||||
|
||||
@@ -7,13 +7,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { ReactNode, RefObject } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
|
||||
interface OptionProps {
|
||||
inputRef?: RefObject<HTMLLIElement>;
|
||||
endAdornment?: ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
@@ -21,8 +20,8 @@ interface OptionProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>(inputRef);
|
||||
export const Option: React.FC<OptionProps> = ({ children, endAdornment, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>();
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { normalize } from "matrix-js-sdk/src/utils";
|
||||
import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||
@@ -90,8 +90,8 @@ interface IProps {
|
||||
onFinished(): void;
|
||||
}
|
||||
|
||||
function refIsForRecentlyViewed(ref?: RefObject<HTMLElement>): boolean {
|
||||
return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
|
||||
function nodeIsForRecentlyViewed(node?: HTMLElement): boolean {
|
||||
return node?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
|
||||
}
|
||||
|
||||
function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
|
||||
@@ -498,13 +498,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
};
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const ref = rovingContext.state.refs[0];
|
||||
if (ref) {
|
||||
const node = rovingContext.state.nodes[0];
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
payload: { node },
|
||||
});
|
||||
ref.current?.scrollIntoView?.({
|
||||
node?.scrollIntoView?.({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
@@ -1128,7 +1128,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
break;
|
||||
}
|
||||
|
||||
let ref: RefObject<HTMLElement> | undefined;
|
||||
let node: HTMLElement | undefined;
|
||||
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
switch (accessibilityAction) {
|
||||
case KeyBindingAction.Escape:
|
||||
@@ -1141,20 +1141,20 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) {
|
||||
let refs = rovingContext.state.refs;
|
||||
if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) {
|
||||
let nodes = rovingContext.state.nodes;
|
||||
if (!query && !filter !== null) {
|
||||
// If the current selection is not in the recently viewed row then only include the
|
||||
// first recently viewed so that is the target when the user is switching into recently viewed.
|
||||
const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
|
||||
? rovingContext.state.activeRef
|
||||
: refs.find(refIsForRecentlyViewed);
|
||||
const keptRecentlyViewedRef = nodeIsForRecentlyViewed(rovingContext.state.activeNode)
|
||||
? rovingContext.state.activeNode
|
||||
: nodes.find(nodeIsForRecentlyViewed);
|
||||
// exclude all other recently viewed items from the list so up/down arrows skip them
|
||||
refs = refs.filter((ref) => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
|
||||
nodes = nodes.filter((ref) => ref === keptRecentlyViewedRef || !nodeIsForRecentlyViewed(ref));
|
||||
}
|
||||
|
||||
const idx = refs.indexOf(rovingContext.state.activeRef);
|
||||
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1164,27 +1164,30 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
if (
|
||||
!query &&
|
||||
!filter !== null &&
|
||||
rovingContext.state.activeRef &&
|
||||
rovingContext.state.refs.length > 0 &&
|
||||
refIsForRecentlyViewed(rovingContext.state.activeRef)
|
||||
rovingContext.state.activeNode &&
|
||||
rovingContext.state.nodes.length > 0 &&
|
||||
nodeIsForRecentlyViewed(rovingContext.state.activeNode)
|
||||
) {
|
||||
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
|
||||
const idx = refs.indexOf(rovingContext.state.activeRef);
|
||||
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1));
|
||||
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(
|
||||
nodes,
|
||||
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
payload: { node },
|
||||
});
|
||||
ref.current?.scrollIntoView({
|
||||
node?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
@@ -1204,12 +1207,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
case KeyBindingAction.Enter:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
rovingContext.state.activeRef?.current?.click();
|
||||
rovingContext.state.activeNode?.click();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const activeDescendant = rovingContext.state.activeRef?.current?.id;
|
||||
const activeDescendant = rovingContext.state.activeNode?.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -12,6 +12,9 @@ import React, {
|
||||
RefObject,
|
||||
createRef,
|
||||
ComponentProps,
|
||||
MutableRefObject,
|
||||
RefCallback,
|
||||
Ref,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { debounce } from "lodash";
|
||||
@@ -75,7 +78,7 @@ interface IProps {
|
||||
|
||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The ref pass through to the input
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
// The element to create. Defaults to "input".
|
||||
element: "input";
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
@@ -84,7 +87,7 @@ export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElemen
|
||||
|
||||
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
|
||||
// The ref pass through to the select
|
||||
inputRef?: RefObject<HTMLSelectElement>;
|
||||
inputRef?: Ref<HTMLSelectElement>;
|
||||
// To define options for a select, use <Field><option ... /></Field>
|
||||
element: "select";
|
||||
// The select's value. This is a controlled component, so the value is required.
|
||||
@@ -93,7 +96,7 @@ interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
|
||||
|
||||
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
// The ref pass through to the textarea
|
||||
inputRef?: RefObject<HTMLTextAreaElement>;
|
||||
inputRef?: Ref<HTMLTextAreaElement>;
|
||||
element: "textarea";
|
||||
// The textarea's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
@@ -101,7 +104,7 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElem
|
||||
|
||||
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The ref pass through to the input
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
element: "input";
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
@@ -118,7 +121,17 @@ interface IState {
|
||||
|
||||
export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||
private readonly id: string;
|
||||
private readonly _inputRef = createRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>();
|
||||
private readonly _inputRef: MutableRefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null> =
|
||||
createRef();
|
||||
|
||||
/**
|
||||
* When props.inputRef is a callback ref, we will pass callbackRef to the DOM element.
|
||||
* This is so that other methods here can still access the DOM element via this._inputRef.
|
||||
*/
|
||||
private readonly callbackRef: RefCallback<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = (node) => {
|
||||
this._inputRef.current = node;
|
||||
(this.props.inputRef as RefCallback<unknown>)(node);
|
||||
};
|
||||
|
||||
public static readonly defaultProps = {
|
||||
element: "input",
|
||||
@@ -230,7 +243,12 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||
}
|
||||
|
||||
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
|
||||
return this.props.inputRef ?? this._inputRef;
|
||||
const inputRef = this.props.inputRef;
|
||||
if (typeof inputRef === "function") {
|
||||
// This is a callback ref, so return _inputRef which will point to the actual DOM element.
|
||||
return this._inputRef;
|
||||
}
|
||||
return (inputRef ?? this._inputRef) as RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
private onTooltipOpenChange = (open: boolean): void => {
|
||||
@@ -284,7 +302,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
|
||||
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
|
||||
...inputProps,
|
||||
ref: this.inputRef,
|
||||
ref: typeof this.props.inputRef === "function" ? this.callbackRef : this.inputRef,
|
||||
};
|
||||
|
||||
const fieldInput = React.createElement(this.props.element, inputProps_, children);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTimeout } from "../../../hooks/useTimeout";
|
||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import AccessibleButton from "./AccessibleButton";
|
||||
import Spinner from "./Spinner";
|
||||
import { getFileChanged } from "../settings/AvatarSetting.tsx";
|
||||
|
||||
export const AVATAR_SIZE = "52px";
|
||||
|
||||
@@ -72,11 +73,12 @@ const MiniAvatarUploader: React.FC<IProps> = ({
|
||||
onClick?.(ev);
|
||||
}}
|
||||
onChange={async (ev): Promise<void> => {
|
||||
if (!ev.target.files?.length) return;
|
||||
setBusy(true);
|
||||
const file = ev.target.files[0];
|
||||
const { content_uri: uri } = await cli.uploadContent(file);
|
||||
await setAvatarUrl(uri);
|
||||
const file = getFileChanged(ev);
|
||||
if (file) {
|
||||
const { content_uri: uri } = await cli.uploadContent(file);
|
||||
await setAvatarUrl(uri);
|
||||
}
|
||||
setBusy(false);
|
||||
}}
|
||||
accept="image/*"
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 from "react";
|
||||
import React, { Ref } from "react";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import classnames from "classnames";
|
||||
|
||||
@@ -16,7 +16,7 @@ export enum CheckboxStyle {
|
||||
}
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
kind?: CheckboxStyle;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ 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 from "react";
|
||||
import React, { Ref } from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
outlined?: boolean;
|
||||
// If true (default), the children will be contained within a <label> element
|
||||
// If false, they'll be in a div. Putting interactive components that have labels
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { clamp } from "../../../utils/numbers";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { Ref } from "../../../accessibility/roving/types";
|
||||
|
||||
export const CATEGORY_HEADER_HEIGHT = 20;
|
||||
export const EMOJI_HEIGHT = 35;
|
||||
@@ -154,47 +153,47 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
};
|
||||
|
||||
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
|
||||
const node = state.activeRef?.current;
|
||||
const node = state.activeNode;
|
||||
const parent = node?.parentElement;
|
||||
if (!parent || !state.activeRef) return;
|
||||
if (!parent || !state.activeNode) return;
|
||||
const rowIndex = Array.from(parent.children).indexOf(node);
|
||||
const refIndex = state.refs.indexOf(state.activeRef);
|
||||
const refIndex = state.nodes.indexOf(state.activeNode);
|
||||
|
||||
let focusRef: Ref | undefined;
|
||||
let focusNode: HTMLElement | undefined;
|
||||
let newParent: HTMLElement | undefined;
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_LEFT:
|
||||
focusRef = state.refs[refIndex - 1];
|
||||
newParent = focusRef?.current?.parentElement ?? undefined;
|
||||
focusNode = state.nodes[refIndex - 1];
|
||||
newParent = focusNode?.parentElement ?? undefined;
|
||||
break;
|
||||
|
||||
case Key.ARROW_RIGHT:
|
||||
focusRef = state.refs[refIndex + 1];
|
||||
newParent = focusRef?.current?.parentElement ?? undefined;
|
||||
focusNode = state.nodes[refIndex + 1];
|
||||
newParent = focusNode?.parentElement ?? undefined;
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
case Key.ARROW_DOWN: {
|
||||
// For up/down we find the prev/next parent by inspecting the refs either side of our row
|
||||
const ref =
|
||||
const node =
|
||||
ev.key === Key.ARROW_UP
|
||||
? state.refs[refIndex - rowIndex - 1]
|
||||
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW];
|
||||
newParent = ref?.current?.parentElement ?? undefined;
|
||||
? state.nodes[refIndex - rowIndex - 1]
|
||||
: state.nodes[refIndex - rowIndex + EMOJIS_PER_ROW];
|
||||
newParent = node?.parentElement ?? undefined;
|
||||
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
|
||||
focusRef = state.refs.find((r) => r.current === newTarget);
|
||||
focusNode = state.nodes.find((r) => r === newTarget);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (focusRef) {
|
||||
if (focusNode) {
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref: focusRef },
|
||||
payload: { node: focusNode },
|
||||
});
|
||||
|
||||
if (parent !== newParent) {
|
||||
focusRef.current?.scrollIntoView({
|
||||
focusNode?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "center",
|
||||
inline: "center",
|
||||
@@ -207,10 +206,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
|
||||
if (
|
||||
state.activeRef?.current &&
|
||||
[Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)
|
||||
) {
|
||||
if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
|
||||
this.keyboardNavigation(ev, state, dispatch);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ class Search extends React.PureComponent<IProps> {
|
||||
onChange={(ev) => this.props.onChange(ev.target.value)}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={this.inputRef}
|
||||
aria-activedescendant={this.context.state.activeRef?.current?.id}
|
||||
aria-activedescendant={this.context.state.activeNode?.id}
|
||||
aria-controls="mx_EmojiPicker_body"
|
||||
aria-haspopup="grid"
|
||||
aria-autocomplete="list"
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface IProps {
|
||||
relation?: IEventRelation;
|
||||
}
|
||||
|
||||
export const LocationButton: React.FC<IProps> = ({ roomId, sender, menuPosition, relation }) => {
|
||||
const LocationButton: React.FC<IProps> = ({ roomId, sender, menuPosition, relation }) => {
|
||||
const overflowMenuCloser = useContext(OverflowMenuContext);
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import LocationMarkerIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid";
|
||||
|
||||
import { Icon as LocationMarkerIcon } from "../../../../res/img/element-icons/location.svg";
|
||||
import { Icon as MapFallbackImage } from "../../../../res/img/location/map.svg";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid";
|
||||
|
||||
import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { HTMLAttributes, useContext } from "react";
|
||||
import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { _t } from "../../../languageHandler";
|
||||
@@ -14,7 +15,6 @@ import { OwnProfileStore } from "../../../stores/OwnProfileStore";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import Heading from "../typography/Heading";
|
||||
import { Icon as LocationIcon } from "../../../../res/img/element-icons/location.svg";
|
||||
import { LocationShareType } from "./shareLocation";
|
||||
import StyledLiveBeaconIcon from "../beacon/StyledLiveBeaconIcon";
|
||||
|
||||
|
||||
@@ -22,16 +22,6 @@ export function Map(props: ComponentProps<typeof MapComponent>): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const LocationPickerComponent = lazy(() => import("./LocationPicker"));
|
||||
|
||||
export function LocationPicker(props: ComponentProps<typeof LocationPickerComponent>): JSX.Element {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<LocationPickerComponent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const SmartMarkerComponent = lazy(() => import("./SmartMarker"));
|
||||
|
||||
export function SmartMarker(props: ComponentProps<typeof SmartMarkerComponent>): JSX.Element {
|
||||
|
||||
@@ -98,25 +98,29 @@ export default class DateSeparator extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private getLabel(): string {
|
||||
const date = new Date(this.props.ts);
|
||||
const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates);
|
||||
try {
|
||||
const date = new Date(this.props.ts);
|
||||
const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates);
|
||||
|
||||
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
|
||||
if (this.props.forExport || disableRelativeTimestamps) return formatFullDateNoTime(date);
|
||||
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
|
||||
if (this.props.forExport || disableRelativeTimestamps) return formatFullDateNoTime(date);
|
||||
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
const days = getDaysArray("long");
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
const days = getDaysArray("long");
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return this.relativeTimeFormat.format(0, "day"); // Today
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return this.relativeTimeFormat.format(-1, "day"); // Yesterday
|
||||
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
|
||||
return days[date.getDay()]; // Sunday-Saturday
|
||||
} else {
|
||||
return formatFullDateNoTime(date);
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return this.relativeTimeFormat.format(0, "day"); // Today
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return this.relativeTimeFormat.format(-1, "day"); // Yesterday
|
||||
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
|
||||
return days[date.getDay()]; // Sunday-Saturday
|
||||
} else {
|
||||
return formatFullDateNoTime(date);
|
||||
}
|
||||
} catch {
|
||||
return _t("common|message_timestamp_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Icon as DownloadIcon } from "../../../../res/img/download.svg";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
@@ -6,18 +6,18 @@ 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, { forwardRef, useContext } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import EventTileBubble from "./EventTileBubble";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { objectHasDiff } from "../../../utils/objects";
|
||||
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
|
||||
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../utils/crypto";
|
||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -25,9 +25,9 @@ interface IProps {
|
||||
}
|
||||
|
||||
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp }, ref) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const cli = useMatrixClientContext();
|
||||
const roomId = mxEvent.getRoomId()!;
|
||||
const isRoomEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
|
||||
const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined);
|
||||
|
||||
const prevContent = mxEvent.getPrevContent() as RoomEncryptionEventContent;
|
||||
const content = mxEvent.getContent<RoomEncryptionEventContent>();
|
||||
|
||||
@@ -23,7 +23,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
|
||||
const dateInputDefaultValue = formatDateForInput(date);
|
||||
|
||||
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
const [onFocus, isActive, refCallback] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onDateValueInput = (ev: React.ChangeEvent<HTMLInputElement>): void => setDateValue(ev.target.value);
|
||||
const onJumpToDateSubmit = (ev: FormEvent): void => {
|
||||
@@ -45,7 +45,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
|
||||
className="mx_JumpToDatePicker_datePicker"
|
||||
label={_t("room|jump_to_date_prompt")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
inputRef={refCallback}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
<RovingAccessibleButton
|
||||
|
||||
@@ -275,7 +275,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let isAnimated = mayBeAnimated(content.info?.mimetype);
|
||||
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
|
||||
|
||||
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
|
||||
// because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail.
|
||||
@@ -298,8 +298,15 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await this.props.mediaEventHelper!.sourceBlob.value;
|
||||
if (!(await blobIsAnimated(content.info?.mimetype, blob))) {
|
||||
// If we didn't receive the MSC4230 is_animated flag
|
||||
// then we need to check if the image is animated by downloading it.
|
||||
if (
|
||||
content.info?.["org.matrix.msc4230.is_animated"] === false ||
|
||||
!(await blobIsAnimated(
|
||||
content.info?.mimetype,
|
||||
await this.props.mediaEventHelper!.sourceBlob.value,
|
||||
))
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,10 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote {
|
||||
|
||||
export function allVotes(voteRelations: Relations): Array<UserVote> {
|
||||
if (voteRelations) {
|
||||
return voteRelations.getRelations().map(userResponseFromPollResponseEvent);
|
||||
return voteRelations
|
||||
.getRelations()
|
||||
.filter((e) => !e.isRedacted())
|
||||
.map(userResponseFromPollResponseEvent);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -28,16 +28,16 @@ import {
|
||||
ReplyIcon,
|
||||
DeleteIcon,
|
||||
RestartIcon,
|
||||
ThreadsIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg";
|
||||
import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg";
|
||||
import { Icon as ThreadIcon } from "../../../../res/img/element-icons/message/thread.svg";
|
||||
import { Icon as ExpandMessageIcon } from "../../../../res/img/element-icons/expand-message.svg";
|
||||
import { Icon as CollapseMessageIcon } from "../../../../res/img/element-icons/collapse-message.svg";
|
||||
import type { Relations } from "matrix-js-sdk/src/matrix";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import dis, { defaultDispatcher } from "../../../dispatcher/dispatcher";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import ContextMenu, { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { isContentActionable, canEditContent, editEvent, canCancel } from "../../../utils/EventUtils";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
@@ -243,7 +243,7 @@ const ReplyInThreadButton: React.FC<IReplyInThreadButton> = ({ mxEvent }) => {
|
||||
onContextMenu={onClick}
|
||||
placement="left"
|
||||
>
|
||||
<ThreadIcon />
|
||||
<ThreadsIcon />
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -323,7 +323,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dis.dispatch({
|
||||
defaultDispatcher.dispatch({
|
||||
action: "reply_to_event",
|
||||
event: this.props.mxEvent,
|
||||
context: this.context.timelineRenderingType,
|
||||
|
||||
@@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import mime from "mime";
|
||||
import React, { createRef } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
M_LOCATION,
|
||||
M_POLL_END,
|
||||
M_POLL_START,
|
||||
IContent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -144,6 +147,103 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the filename extension and advertised mimetype
|
||||
* of the supplied image/file message content match and are actuallly video/image content.
|
||||
* For image/video messages with a thumbnail it also validates the mimetype is an image.
|
||||
* @param content The mxEvent content of the message
|
||||
* @returns A boolean indicating whether the validation passed
|
||||
*/
|
||||
private validateImageOrVideoMimetype = (content: IContent): boolean => {
|
||||
// As per the spec if filename is not present the body represents the filename
|
||||
const filename = content.filename ?? content.body;
|
||||
if (!filename) {
|
||||
logger.log("Failed to validate image/video content, filename null");
|
||||
return false;
|
||||
}
|
||||
// Check mimetype of the thumbnail
|
||||
if (!this.validateThumbnailMimetype(content)) {
|
||||
logger.log("Failed to validate file/image thumbnail");
|
||||
return false;
|
||||
}
|
||||
|
||||
// if there is no mimetype from the extesion or the mimetype is not image/video validation fails
|
||||
const typeFromExtension = mime.getType(filename) ?? undefined;
|
||||
const extensionMajorMimetype = this.parseMajorMimetype(typeFromExtension);
|
||||
if (!typeFromExtension || !this.validateAllowedMimetype(typeFromExtension, ["image", "video"])) {
|
||||
logger.log("Failed to validate image/video content, invalid or missing extension");
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the content mimetype is set check it is an image/video and that it matches the extesion mimetype otherwise validation fails
|
||||
const contentMimetype = content.info?.mimetype;
|
||||
if (contentMimetype) {
|
||||
const contentMajorMimetype = this.parseMajorMimetype(contentMimetype);
|
||||
if (
|
||||
!this.validateAllowedMimetype(contentMimetype, ["image", "video"]) ||
|
||||
extensionMajorMimetype !== contentMajorMimetype
|
||||
) {
|
||||
logger.log("Failed to validate image/video content, invalid or missing mimetype");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the advertised mimetype of the sticker content
|
||||
* is an image.
|
||||
* For stickers with a thumbnail it also validates the mimetype is an image.
|
||||
* @param content The mxEvent content of the message
|
||||
* @returns A boolean indicating whether the validation passed
|
||||
*/
|
||||
private validateStickerMimetype = (content: IContent): boolean => {
|
||||
// Validate mimetype of the thumbnail
|
||||
const thumbnailResult = this.validateThumbnailMimetype(content);
|
||||
if (!thumbnailResult) {
|
||||
logger.log("Failed to validate sticker thumbnail");
|
||||
return false;
|
||||
}
|
||||
// Validate mimetype of the content info is valid if it is set
|
||||
const contentMimetype = content.info?.mimetype;
|
||||
if (contentMimetype && !this.validateAllowedMimetype(contentMimetype, ["image"])) {
|
||||
logger.log("Failed to validate image/video content, invalid or missing mimetype/extensions");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* For image/video messages or stickers that have a thumnail mimetype specified,
|
||||
* validates that the major mimetime is image.
|
||||
* @param content The mxEvent content of the message
|
||||
* @returns A boolean indicating whether the validation passed
|
||||
*/
|
||||
private validateThumbnailMimetype = (content: IContent): boolean => {
|
||||
const thumbnailMimetype = content.info?.thumbnail_info?.mimetype;
|
||||
return !thumbnailMimetype || this.validateAllowedMimetype(thumbnailMimetype, ["image"]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that the major part of a mimetime from an allowed list.
|
||||
* @param mimetype The mimetype to validate
|
||||
* @param allowedMajorMimeTypes The list of allowed major mimetimes
|
||||
* @returns A boolean indicating whether the validation passed
|
||||
*/
|
||||
private validateAllowedMimetype = (mimetype: string, allowedMajorMimeTypes: string[]): boolean => {
|
||||
const majorMimetype = this.parseMajorMimetype(mimetype);
|
||||
return !!majorMimetype && allowedMajorMimeTypes.includes(majorMimetype);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and returns the the major part of a mimetype(before the "/").
|
||||
* @param mimetype As optional mimetype string to parse
|
||||
* @returns The major part of the mimetype string or undefined
|
||||
*/
|
||||
private parseMajorMimetype(mimetype?: string): string | undefined {
|
||||
return mimetype?.split("/")[0];
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const type = this.props.mxEvent.getType();
|
||||
@@ -165,6 +265,13 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||
BodyType = UnknownBody;
|
||||
}
|
||||
|
||||
if (
|
||||
((BodyType === MImageBody || BodyType == MVideoBody) && !this.validateImageOrVideoMimetype(content)) ||
|
||||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||
) {
|
||||
BodyType = this.bodyTypes.get(MsgType.File)!;
|
||||
}
|
||||
|
||||
// TODO: move to eventTypes when location sharing spec stabilises
|
||||
if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) {
|
||||
BodyType = MLocationBody;
|
||||
|
||||
@@ -9,107 +9,144 @@ 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, { ReactNode } from "react";
|
||||
import React, { ReactNode, JSX } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
import SettingsFieldset from "../settings/SettingsFieldset";
|
||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||
import { useSettingValueAt } from "../../../hooks/useSettings.ts";
|
||||
|
||||
interface IProps {
|
||||
/**
|
||||
* The URL preview settings for a room
|
||||
*/
|
||||
interface UrlPreviewSettingsProps {
|
||||
/**
|
||||
* The room.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export default class UrlPreviewSettings extends React.Component<IProps> {
|
||||
private onClickUserSettings = (e: ButtonEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
};
|
||||
export function UrlPreviewSettings({ room }: UrlPreviewSettingsProps): JSX.Element {
|
||||
const { roomId } = room;
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const isEncrypted = useIsEncrypted(matrixClient, room);
|
||||
const isLoading = isEncrypted === null;
|
||||
|
||||
public render(): ReactNode {
|
||||
const roomId = this.props.room.roomId;
|
||||
const isEncrypted = MatrixClientPeg.safeGet().isRoomEncrypted(roomId);
|
||||
|
||||
let previewsForAccount: ReactNode | undefined;
|
||||
let previewsForRoom: ReactNode | undefined;
|
||||
|
||||
if (!isEncrypted) {
|
||||
// Only show account setting state and room state setting state in non-e2ee rooms where they apply
|
||||
const accountEnabled = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
|
||||
if (accountEnabled) {
|
||||
previewsForAccount = _t(
|
||||
"room_settings|general|user_url_previews_default_on",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onClickUserSettings}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
previewsForAccount = _t(
|
||||
"room_settings|general|user_url_previews_default_off",
|
||||
{},
|
||||
{
|
||||
a: (sub) => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onClickUserSettings}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
|
||||
previewsForRoom = (
|
||||
return (
|
||||
<SettingsFieldset
|
||||
legend={_t("room_settings|general|url_previews_section")}
|
||||
description={!isLoading && <Description isEncrypted={isEncrypted} />}
|
||||
>
|
||||
{isLoading ? (
|
||||
<InlineSpinner />
|
||||
) : (
|
||||
<>
|
||||
<PreviewsForRoom isEncrypted={isEncrypted} roomId={roomId} />
|
||||
<SettingsFlag
|
||||
name="urlPreviewsEnabled"
|
||||
level={SettingLevel.ROOM}
|
||||
name={isEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"}
|
||||
level={SettingLevel.ROOM_DEVICE}
|
||||
roomId={roomId}
|
||||
isExplicit={true}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let str = _td("room_settings|general|default_url_previews_on");
|
||||
if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled", roomId, /*explicit=*/ true)) {
|
||||
str = _td("room_settings|general|default_url_previews_off");
|
||||
}
|
||||
previewsForRoom = <div>{_t(str)}</div>;
|
||||
}
|
||||
} else {
|
||||
previewsForAccount = _t("room_settings|general|url_preview_encryption_warning");
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</SettingsFieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const previewsForRoomAccount = // in an e2ee room we use a special key to enforce per-room opt-in
|
||||
(
|
||||
<SettingsFlag
|
||||
name={isEncrypted ? "urlPreviewsEnabled_e2ee" : "urlPreviewsEnabled"}
|
||||
level={SettingLevel.ROOM_DEVICE}
|
||||
roomId={roomId}
|
||||
/>
|
||||
);
|
||||
/**
|
||||
* Click handler for the user settings link
|
||||
* @param e
|
||||
*/
|
||||
function onClickUserSettings(e: ButtonEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dis.fire(Action.ViewUserSettings);
|
||||
}
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<p>{_t("room_settings|general|url_preview_explainer")}</p>
|
||||
<p>{previewsForAccount}</p>
|
||||
</>
|
||||
/**
|
||||
* The description for the URL preview settings
|
||||
*/
|
||||
interface DescriptionProps {
|
||||
/**
|
||||
* Whether the room is encrypted
|
||||
*/
|
||||
isEncrypted: boolean;
|
||||
}
|
||||
|
||||
function Description({ isEncrypted }: DescriptionProps): JSX.Element {
|
||||
const urlPreviewsEnabled = useSettingValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
|
||||
|
||||
let previewsForAccount: ReactNode | undefined;
|
||||
if (isEncrypted) {
|
||||
previewsForAccount = _t("room_settings|general|url_preview_encryption_warning");
|
||||
} else {
|
||||
const button = {
|
||||
a: (sub: string) => (
|
||||
<AccessibleButton kind="link_inline" onClick={onClickUserSettings}>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
};
|
||||
|
||||
previewsForAccount = urlPreviewsEnabled
|
||||
? _t("room_settings|general|user_url_previews_default_on", {}, button)
|
||||
: _t("room_settings|general|user_url_previews_default_off", {}, button);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{_t("room_settings|general|url_preview_explainer")}</p>
|
||||
<p>{previewsForAccount}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The description for the URL preview settings
|
||||
*/
|
||||
interface PreviewsForRoomProps {
|
||||
/**
|
||||
* Whether the room is encrypted
|
||||
*/
|
||||
isEncrypted: boolean;
|
||||
/**
|
||||
* The room ID
|
||||
*/
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function PreviewsForRoom({ isEncrypted, roomId }: PreviewsForRoomProps): JSX.Element | null {
|
||||
const urlPreviewsEnabled = useSettingValueAt(
|
||||
SettingLevel.ACCOUNT,
|
||||
"urlPreviewsEnabled",
|
||||
roomId,
|
||||
/*explicit=*/ true,
|
||||
);
|
||||
if (isEncrypted) return null;
|
||||
|
||||
let previewsForRoom: ReactNode;
|
||||
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, SettingLevel.ROOM)) {
|
||||
previewsForRoom = (
|
||||
<SettingsFlag name="urlPreviewsEnabled" level={SettingLevel.ROOM} roomId={roomId} isExplicit={true} />
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsFieldset legend={_t("room_settings|general|url_previews_section")} description={description}>
|
||||
{previewsForRoom}
|
||||
{previewsForRoomAccount}
|
||||
</SettingsFieldset>
|
||||
} else {
|
||||
previewsForRoom = (
|
||||
<div>
|
||||
{urlPreviewsEnabled
|
||||
? _t("room_settings|general|default_url_previews_on")
|
||||
: _t("room_settings|general|default_url_previews_off")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return previewsForRoom;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, { createRef, KeyboardEvent, RefObject } from "react";
|
||||
import classNames from "classnames";
|
||||
import { flatMap } from "lodash";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { defer } from "matrix-js-sdk/src/utils";
|
||||
|
||||
import Autocompleter, { ICompletion, ISelectionRange, IProviderCompletions } from "../../../autocomplete/Autocompleter";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -127,18 +128,21 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
private async processQuery(query: string, selection: ISelectionRange): Promise<void> {
|
||||
return this.autocompleter
|
||||
?.getCompletions(query, selection, this.state.forceComplete, MAX_PROVIDER_MATCHES)
|
||||
.then((completions) => {
|
||||
// Only ever process the completions for the most recent query being processed
|
||||
if (query !== this.queryRequested) {
|
||||
return;
|
||||
}
|
||||
this.processCompletions(completions);
|
||||
});
|
||||
if (!this.autocompleter) return;
|
||||
const completions = await this.autocompleter.getCompletions(
|
||||
query,
|
||||
selection,
|
||||
this.state.forceComplete,
|
||||
MAX_PROVIDER_MATCHES,
|
||||
);
|
||||
// Only ever process the completions for the most recent query being processed
|
||||
if (query !== this.queryRequested) {
|
||||
return;
|
||||
}
|
||||
await this.processCompletions(completions);
|
||||
}
|
||||
|
||||
private processCompletions(completions: IProviderCompletions[]): void {
|
||||
private async processCompletions(completions: IProviderCompletions[]): Promise<void> {
|
||||
const completionList = flatMap(completions, (provider) => provider.completions);
|
||||
|
||||
// Reset selection when completion list becomes empty.
|
||||
@@ -169,14 +173,19 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
completions,
|
||||
completionList,
|
||||
selectionOffset,
|
||||
hide,
|
||||
// Force complete is turned off each time since we can't edit the query in that case
|
||||
forceComplete: false,
|
||||
});
|
||||
const deferred = defer<void>();
|
||||
this.setState(
|
||||
{
|
||||
completions,
|
||||
completionList,
|
||||
selectionOffset,
|
||||
hide,
|
||||
// Force complete is turned off each time since we can't edit the query in that case
|
||||
forceComplete: false,
|
||||
},
|
||||
deferred.resolve,
|
||||
);
|
||||
await deferred.promise;
|
||||
}
|
||||
|
||||
public hasSelection(): boolean {
|
||||
|
||||
@@ -19,9 +19,7 @@ import { XOR } from "../../../@types/common";
|
||||
export enum E2EState {
|
||||
Verified = "verified",
|
||||
Warning = "warning",
|
||||
Unknown = "unknown",
|
||||
Normal = "normal",
|
||||
Unauthenticated = "unauthenticated",
|
||||
}
|
||||
|
||||
const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
|
||||
|
||||
@@ -757,6 +757,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
case EventShieldReason.MISMATCHED_SENDER_KEY:
|
||||
shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key");
|
||||
break;
|
||||
|
||||
case EventShieldReason.SENT_IN_CLEAR:
|
||||
shieldReasonMessage = _t("common|unencrypted");
|
||||
break;
|
||||
|
||||
case EventShieldReason.VERIFICATION_VIOLATION:
|
||||
shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified");
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.state.shieldColour === EventShieldColour.GREY) {
|
||||
|
||||
@@ -7,11 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../accessibility/RovingTabIndex";
|
||||
import Toolbar from "../../../../accessibility/Toolbar";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg";
|
||||
import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg";
|
||||
import { ButtonEvent } from "../../elements/AccessibleButton";
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import E2EIcon from "./E2EIcon";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { aboveLeftOf, MenuProps } from "../../structures/ContextMenu";
|
||||
import ReplyPreview from "./ReplyPreview";
|
||||
import { UserIdentityWarning } from "./UserIdentityWarning";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
||||
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
||||
@@ -414,7 +415,7 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
this.messageComposerInput.current?.sendMessage();
|
||||
|
||||
if (this.state.isWysiwygLabEnabled) {
|
||||
const { permalinkCreator, relation, replyToEvent } = this.props;
|
||||
const { relation, replyToEvent } = this.props;
|
||||
const composerContent = this.state.composerContent;
|
||||
this.setState({ composerContent: "", initialComposerContent: "" });
|
||||
dis.dispatch({
|
||||
@@ -424,7 +425,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
await sendMessage(composerContent, this.state.isRichTextEnabled, {
|
||||
mxClient: this.props.mxClient,
|
||||
roomContext: this.context,
|
||||
permalinkCreator,
|
||||
relation,
|
||||
replyToEvent,
|
||||
});
|
||||
@@ -582,7 +582,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
key="controls_input"
|
||||
room={this.props.room}
|
||||
placeholder={this.renderPlaceholderText()}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
relation={this.props.relation}
|
||||
replyToEvent={this.props.replyToEvent}
|
||||
onChange={this.onChange}
|
||||
@@ -597,7 +596,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
key="controls_voice_record"
|
||||
ref={this.voiceRecordingButton}
|
||||
room={this.props.room}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
relation={this.props.relation}
|
||||
replyToEvent={this.props.replyToEvent}
|
||||
/>,
|
||||
@@ -642,8 +640,6 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
let recordingTooltip: JSX.Element | undefined;
|
||||
|
||||
const isTooltipOpen = Boolean(this.state.recordingTimeLeftSeconds);
|
||||
const secondsLeft = this.state.recordingTimeLeftSeconds ? Math.round(this.state.recordingTimeLeftSeconds) : 0;
|
||||
|
||||
@@ -673,8 +669,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
return (
|
||||
<Tooltip open={isTooltipOpen} description={formatTimeLeft(secondsLeft)} placement="bottom">
|
||||
<div className={classes} ref={this.ref} role="region" aria-label={_t("a11y|message_composer")}>
|
||||
{recordingTooltip}
|
||||
<div className="mx_MessageComposer_wrapper">
|
||||
<UserIdentityWarning room={this.props.room} key={this.props.room.roomId} />
|
||||
<ReplyPreview
|
||||
replyToEvent={this.props.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
|
||||
@@ -47,7 +47,6 @@ import { CHAT_EFFECTS } from "../../../effects";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
@@ -177,8 +176,6 @@ export function createMessageContent(
|
||||
model: EditorModel,
|
||||
replyToEvent: MatrixEvent | undefined,
|
||||
relation: IEventRelation | undefined,
|
||||
permalinkCreator?: RoomPermalinkCreator,
|
||||
includeReplyLegacyFallback = true,
|
||||
): RoomMessageEventContent {
|
||||
const isEmote = containsEmote(model);
|
||||
if (isEmote) {
|
||||
@@ -209,10 +206,7 @@ export function createMessageContent(
|
||||
|
||||
attachRelation(content, relation);
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
permalinkCreator,
|
||||
includeLegacyFallback: includeReplyLegacyFallback,
|
||||
});
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -238,12 +232,10 @@ export function isQuickReaction(model: EditorModel): boolean {
|
||||
interface ISendMessageComposerProps extends MatrixClientProps {
|
||||
room: Room;
|
||||
placeholder?: string;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
disabled?: boolean;
|
||||
onChange?(model: EditorModel): void;
|
||||
includeReplyLegacyFallback?: boolean;
|
||||
toggleStickerPickerOpen: () => void;
|
||||
}
|
||||
|
||||
@@ -258,10 +250,6 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
private dispatcherRef?: string;
|
||||
private sendHistoryManager: SendHistoryManager;
|
||||
|
||||
public static defaultProps = {
|
||||
includeReplyLegacyFallback: true,
|
||||
};
|
||||
|
||||
public constructor(props: ISendMessageComposerProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
|
||||
@@ -500,11 +488,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
attachMentions(this.props.mxClient.getSafeUserId(), content, model, replyToEvent);
|
||||
attachRelation(content, this.props.relation);
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
// Exclude the legacy fallback for custom event types such as those used by /fireworks
|
||||
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
|
||||
});
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
}
|
||||
} else {
|
||||
shouldSend = false;
|
||||
@@ -534,8 +518,6 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
|
||||
model,
|
||||
replyToEvent,
|
||||
this.props.relation,
|
||||
this.props.permalinkCreator,
|
||||
this.props.includeReplyLegacyFallback,
|
||||
);
|
||||
}
|
||||
// don't bother sending an empty message
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
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 React, { useCallback, useRef, useState } from "react";
|
||||
import { EventType, KnownMembership, MatrixEvent, Room, RoomStateEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoApi, CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button, Separator } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
|
||||
interface UserIdentityWarningProps {
|
||||
/**
|
||||
* The current room being viewed.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* The ID of the room being viewed. This is used to ensure that the
|
||||
* component's state and references are cleared when the room changes.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the given user's identity need to be approved?
|
||||
*/
|
||||
async function userNeedsApproval(crypto: CryptoApi, userId: string): Promise<boolean> {
|
||||
const verificationStatus = await crypto.getUserVerificationStatus(userId);
|
||||
return verificationStatus.needsUserApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the component is uninitialised, is in the process of initialising, or
|
||||
* has completed initialising.
|
||||
*/
|
||||
enum InitialisationStatus {
|
||||
Uninitialised,
|
||||
Initialising,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a banner warning when there is an issue with a user's identity.
|
||||
*
|
||||
* Warns when an unverified user's identity has changed, and gives the user a
|
||||
* button to acknowledge the change.
|
||||
*/
|
||||
export const UserIdentityWarning: React.FC<UserIdentityWarningProps> = ({ room }) => {
|
||||
const cli = useMatrixClientContext();
|
||||
const crypto = cli.getCrypto();
|
||||
|
||||
// The current room member that we are prompting the user to approve.
|
||||
// `undefined` means we are not currently showing a prompt.
|
||||
const [currentPrompt, setCurrentPrompt] = useState<RoomMember | undefined>(undefined);
|
||||
|
||||
// Whether or not we've already initialised the component by loading the
|
||||
// room membership.
|
||||
const initialisedRef = useRef<InitialisationStatus>(InitialisationStatus.Uninitialised);
|
||||
// Which room members need their identity approved.
|
||||
const membersNeedingApprovalRef = useRef<Map<string, RoomMember>>(new Map());
|
||||
// For each user, we assign a sequence number to each verification status
|
||||
// that we get, or fetch.
|
||||
//
|
||||
// Since fetching a verification status is asynchronous, we could get an
|
||||
// update in the middle of fetching the verification status, which could
|
||||
// mean that the status that we fetched is out of date. So if the current
|
||||
// sequence number is not the same as the sequence number when we started
|
||||
// the fetch, then we drop our fetched result, under the assumption that the
|
||||
// update that we received is the most up-to-date version. If it is in fact
|
||||
// not the most up-to-date version, then we should be receiving a new update
|
||||
// soon with the newer value, so it will fix itself in the end.
|
||||
//
|
||||
// We also assign a sequence number when the user leaves the room, in order
|
||||
// to prevent prompting about a user who leaves while we are fetching their
|
||||
// verification status.
|
||||
const verificationStatusSequencesRef = useRef<Map<string, number>>(new Map());
|
||||
const incrementVerificationStatusSequence = (userId: string): number => {
|
||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||
const value = verificationStatusSequences.get(userId);
|
||||
const newValue = value === undefined ? 1 : value + 1;
|
||||
verificationStatusSequences.set(userId, newValue);
|
||||
return newValue;
|
||||
};
|
||||
|
||||
// Update the current prompt. Select a new user if needed, or hide the
|
||||
// warning if we don't have anyone to warn about.
|
||||
const updateCurrentPrompt = useCallback((): undefined => {
|
||||
const membersNeedingApproval = membersNeedingApprovalRef.current;
|
||||
// We have to do this in a callback to `setCurrentPrompt`
|
||||
// because this function could have been called after an
|
||||
// `await`, and the `currentPrompt` that this function would
|
||||
// have may be outdated.
|
||||
setCurrentPrompt((currentPrompt) => {
|
||||
// If we're already displaying a warning, and that user still needs
|
||||
// approval, continue showing that user.
|
||||
if (currentPrompt && membersNeedingApproval.has(currentPrompt.userId)) return currentPrompt;
|
||||
|
||||
if (membersNeedingApproval.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We pick the user with the smallest user ID.
|
||||
const keys = Array.from(membersNeedingApproval.keys()).sort((a, b) => a.localeCompare(b));
|
||||
const selection = membersNeedingApproval.get(keys[0]!);
|
||||
return selection;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Add a user to the membersNeedingApproval map, and update the current
|
||||
// prompt if necessary. The user will only be added if they are actually a
|
||||
// member of the room. If they are not a member, this function will do
|
||||
// nothing.
|
||||
const addMemberNeedingApproval = useCallback(
|
||||
(userId: string, member?: RoomMember): void => {
|
||||
if (userId === cli.getUserId()) {
|
||||
// We always skip our own user, because we can't pin our own identity.
|
||||
return;
|
||||
}
|
||||
member = member ?? room.getMember(userId) ?? undefined;
|
||||
if (!member) return;
|
||||
|
||||
membersNeedingApprovalRef.current.set(userId, member);
|
||||
// We only select the prompt if we are done initialising,
|
||||
// because we will select the prompt after we're done
|
||||
// initialising, and we want to start by displaying a warning
|
||||
// for the user with the smallest ID.
|
||||
if (initialisedRef.current === InitialisationStatus.Completed) {
|
||||
updateCurrentPrompt();
|
||||
}
|
||||
},
|
||||
[cli, room, updateCurrentPrompt],
|
||||
);
|
||||
|
||||
// For each user in the list check if their identity needs approval, and if
|
||||
// so, add them to the membersNeedingApproval map and update the prompt if
|
||||
// needed.
|
||||
const addMembersWhoNeedApproval = useCallback(
|
||||
async (members: RoomMember[]): Promise<void> => {
|
||||
const verificationStatusSequences = verificationStatusSequencesRef.current;
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const member of members) {
|
||||
const userId = member.userId;
|
||||
const sequenceNum = incrementVerificationStatusSequence(userId);
|
||||
promises.push(
|
||||
userNeedsApproval(crypto!, userId).then((needsApproval) => {
|
||||
if (needsApproval) {
|
||||
// Only actually update the list if we have the most
|
||||
// recent value.
|
||||
if (verificationStatusSequences.get(userId) === sequenceNum) {
|
||||
addMemberNeedingApproval(userId, member);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
[crypto, addMemberNeedingApproval],
|
||||
);
|
||||
|
||||
// Remove a user from the membersNeedingApproval map, and update the current
|
||||
// prompt if necessary.
|
||||
const removeMemberNeedingApproval = useCallback(
|
||||
(userId: string): void => {
|
||||
membersNeedingApprovalRef.current.delete(userId);
|
||||
updateCurrentPrompt();
|
||||
},
|
||||
[updateCurrentPrompt],
|
||||
);
|
||||
|
||||
// Initialise the component. Get the room members, check which ones need
|
||||
// their identity approved, and pick one to display.
|
||||
const loadMembers = useCallback(async (): Promise<void> => {
|
||||
if (!crypto || initialisedRef.current !== InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
// If encryption is not enabled in the room, we don't need to do
|
||||
// anything. If encryption gets enabled later, we will retry, via
|
||||
// onRoomStateEvent.
|
||||
if (!(await crypto.isEncryptionEnabledInRoom(room.roomId))) {
|
||||
return;
|
||||
}
|
||||
initialisedRef.current = InitialisationStatus.Initialising;
|
||||
|
||||
const members = await room.getEncryptionTargetMembers();
|
||||
await addMembersWhoNeedApproval(members);
|
||||
|
||||
updateCurrentPrompt();
|
||||
initialisedRef.current = InitialisationStatus.Completed;
|
||||
}, [crypto, room, addMembersWhoNeedApproval, updateCurrentPrompt]);
|
||||
|
||||
loadMembers().catch((e) => {
|
||||
logger.error("Error initialising UserIdentityWarning:", e);
|
||||
});
|
||||
|
||||
// When a user's verification status changes, we check if they need to be
|
||||
// added/removed from the set of members needing approval.
|
||||
const onUserVerificationStatusChanged = useCallback(
|
||||
(userId: string, verificationStatus: UserVerificationStatus): void => {
|
||||
// If we haven't started initialising, that means that we're in a
|
||||
// room where we don't need to display any warnings.
|
||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
|
||||
incrementVerificationStatusSequence(userId);
|
||||
|
||||
if (verificationStatus.needsUserApproval) {
|
||||
addMemberNeedingApproval(userId);
|
||||
} else {
|
||||
removeMemberNeedingApproval(userId);
|
||||
}
|
||||
},
|
||||
[addMemberNeedingApproval, removeMemberNeedingApproval],
|
||||
);
|
||||
useTypedEventEmitter(cli, CryptoEvent.UserTrustStatusChanged, onUserVerificationStatusChanged);
|
||||
|
||||
// We watch for encryption events (since we only display warnings in
|
||||
// encrypted rooms), and for membership changes (since we only display
|
||||
// warnings for users in the room).
|
||||
const onRoomStateEvent = useCallback(
|
||||
async (event: MatrixEvent): Promise<void> => {
|
||||
if (!crypto || event.getRoomId() !== room.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventType = event.getType();
|
||||
if (eventType === EventType.RoomEncryption && event.getStateKey() === "") {
|
||||
// Room is now encrypted, so we can initialise the component.
|
||||
return loadMembers().catch((e) => {
|
||||
logger.error("Error initialising UserIdentityWarning:", e);
|
||||
});
|
||||
} else if (eventType !== EventType.RoomMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We're processing an m.room.member event
|
||||
|
||||
if (initialisedRef.current === InitialisationStatus.Uninitialised) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = event.getStateKey();
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
if (
|
||||
event.getContent().membership === KnownMembership.Join ||
|
||||
(event.getContent().membership === KnownMembership.Invite && room.shouldEncryptForInvitedMembers())
|
||||
) {
|
||||
// Someone's membership changed and we will now encrypt to them. If
|
||||
// their identity needs approval, show a warning.
|
||||
const member = room.getMember(userId);
|
||||
if (member) {
|
||||
await addMembersWhoNeedApproval([member]).catch((e) => {
|
||||
logger.error("Error adding member in UserIdentityWarning:", e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Someone's membership changed and we no longer encrypt to them.
|
||||
// If we're showing a warning about them, we don't need to any more.
|
||||
removeMemberNeedingApproval(userId);
|
||||
incrementVerificationStatusSequence(userId);
|
||||
}
|
||||
},
|
||||
[crypto, room, addMembersWhoNeedApproval, removeMemberNeedingApproval, loadMembers],
|
||||
);
|
||||
useTypedEventEmitter(cli, RoomStateEvent.Events, onRoomStateEvent);
|
||||
|
||||
if (!crypto || !currentPrompt) return null;
|
||||
|
||||
const confirmIdentity = async (): Promise<void> => {
|
||||
await crypto.pinCurrentUserIdentity(currentPrompt.userId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx_UserIdentityWarning">
|
||||
<Separator />
|
||||
<div className="mx_UserIdentityWarning_row">
|
||||
<MemberAvatar member={currentPrompt} title={currentPrompt.userId} size="30px" />
|
||||
<span className="mx_UserIdentityWarning_main">
|
||||
{currentPrompt.rawDisplayName === currentPrompt.userId
|
||||
? _t(
|
||||
"encryption|pinned_identity_changed_no_displayname",
|
||||
{ userId: currentPrompt.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
)
|
||||
: _t(
|
||||
"encryption|pinned_identity_changed",
|
||||
{ displayName: currentPrompt.rawDisplayName, userId: currentPrompt.userId },
|
||||
{
|
||||
a: substituteATag,
|
||||
b: substituteBTag,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<Button kind="primary" size="sm" onClick={confirmIdentity}>
|
||||
{_t("action|ok")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function substituteATag(sub: string): React.ReactNode {
|
||||
return (
|
||||
<a href="https://element.io/help#encryption18" target="_blank" rel="noreferrer noopener">
|
||||
{sub}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function substituteBTag(sub: string): React.ReactNode {
|
||||
return <b>{sub}</b>;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { doMaybeLocalRoomAction } from "../../../utils/local-room";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { attachMentions, attachRelation } from "./SendMessageComposer";
|
||||
import { addReplyToMessageContent } from "../../../utils/Reply";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||
import { createVoiceMessageContent } from "../../../utils/createVoiceMessageContent";
|
||||
@@ -39,7 +38,6 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
}
|
||||
@@ -93,7 +91,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||
throw new Error("No recording started - cannot send anything");
|
||||
}
|
||||
|
||||
const { replyToEvent, relation, permalinkCreator } = this.props;
|
||||
const { replyToEvent, relation } = this.props;
|
||||
|
||||
await this.state.recorder.stop();
|
||||
|
||||
@@ -124,10 +122,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||
attachMentions(MatrixClientPeg.safeGet().getSafeUserId(), content, null, replyToEvent);
|
||||
attachRelation(content, relation);
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
permalinkCreator,
|
||||
includeLegacyFallback: true,
|
||||
});
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
// Clear reply_to_event as we put the message into the queue
|
||||
// if the send fails, retry will handle resending.
|
||||
defaultDispatcher.dispatch({
|
||||
|
||||
@@ -11,7 +11,7 @@ import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/sr
|
||||
import { ReplacementEvent, RoomMessageEventContent, RoomMessageTextEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { parsePermalink, RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { addReplyToMessageContent } from "../../../../../utils/Reply";
|
||||
import { isNotNull } from "../../../../../Typeguards";
|
||||
|
||||
@@ -52,8 +52,6 @@ function getTextReplyFallback(mxEvent: MatrixEvent): string {
|
||||
interface CreateMessageContentParams {
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
includeReplyLegacyFallback?: boolean;
|
||||
editedEvent?: MatrixEvent;
|
||||
}
|
||||
|
||||
@@ -62,13 +60,7 @@ const isMatrixEvent = (e: MatrixEvent | undefined): e is MatrixEvent => e instan
|
||||
export async function createMessageContent(
|
||||
message: string,
|
||||
isHTML: boolean,
|
||||
{
|
||||
relation,
|
||||
replyToEvent,
|
||||
permalinkCreator,
|
||||
includeReplyLegacyFallback = true,
|
||||
editedEvent,
|
||||
}: CreateMessageContentParams,
|
||||
{ relation, replyToEvent, editedEvent }: CreateMessageContentParams,
|
||||
): Promise<RoomMessageEventContent> {
|
||||
const isEditing = isMatrixEvent(editedEvent);
|
||||
const isReply = isEditing ? Boolean(editedEvent.replyEventId) : isMatrixEvent(replyToEvent);
|
||||
@@ -126,11 +118,8 @@ export async function createMessageContent(
|
||||
// TODO Handle editing?
|
||||
attachRelation(content, newRelation);
|
||||
|
||||
if (!isEditing && replyToEvent && permalinkCreator) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
permalinkCreator,
|
||||
includeLegacyFallback: includeReplyLegacyFallback,
|
||||
});
|
||||
if (!isEditing && replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
}
|
||||
|
||||
return content;
|
||||
|
||||
@@ -19,7 +19,6 @@ import { RoomMessageEventContent } from "matrix-js-sdk/src/types";
|
||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics";
|
||||
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
|
||||
import { doMaybeLocalRoomAction } from "../../../../../utils/local-room";
|
||||
import { CHAT_EFFECTS } from "../../../../../effects";
|
||||
import { containsEmoji } from "../../../../../effects/utils";
|
||||
@@ -41,8 +40,6 @@ export interface SendMessageParams {
|
||||
relation?: IEventRelation;
|
||||
replyToEvent?: MatrixEvent;
|
||||
roomContext: IRoomState;
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
includeReplyLegacyFallback?: boolean;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
@@ -50,7 +47,7 @@ export async function sendMessage(
|
||||
isHTML: boolean,
|
||||
{ roomContext, mxClient, ...params }: SendMessageParams,
|
||||
): Promise<ISendEventResponse | undefined> {
|
||||
const { relation, replyToEvent, permalinkCreator } = params;
|
||||
const { relation, replyToEvent } = params;
|
||||
const { room } = roomContext;
|
||||
const roomId = room?.roomId;
|
||||
|
||||
@@ -95,11 +92,7 @@ export async function sendMessage(
|
||||
) {
|
||||
attachRelation(content, relation);
|
||||
if (replyToEvent) {
|
||||
addReplyToMessageContent(content, replyToEvent, {
|
||||
permalinkCreator,
|
||||
// Exclude the legacy fallback for custom event types such as those used by /fireworks
|
||||
includeLegacyFallback: content.msgtype?.startsWith("m.") ?? true,
|
||||
});
|
||||
addReplyToMessageContent(content, replyToEvent);
|
||||
}
|
||||
} else {
|
||||
// instead of setting shouldSend to false as in SendMessageComposer, just return
|
||||
|
||||
@@ -19,6 +19,8 @@ import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||
import { useId } from "../../../utils/useId";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import Modal from "../../../Modal.tsx";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog.tsx";
|
||||
|
||||
interface MenuProps {
|
||||
trigger: ReactNode;
|
||||
@@ -103,6 +105,18 @@ interface IProps {
|
||||
placeholderName: string;
|
||||
}
|
||||
|
||||
export function getFileChanged(e: React.ChangeEvent<HTMLInputElement>): File | null {
|
||||
if (!e.target.files?.length) return null;
|
||||
const file = e.target.files[0];
|
||||
if (file.type.startsWith("image/")) return file;
|
||||
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("upload_failed_title"),
|
||||
description: _t("upload_file|not_image"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for setting or removing an avatar on something (eg. a user or a room)
|
||||
*/
|
||||
@@ -139,7 +153,10 @@ const AvatarSetting: React.FC<IProps> = ({
|
||||
|
||||
const onFileChanged = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) onChange?.(e.target.files[0]);
|
||||
const file = getFileChanged(e);
|
||||
if (file) {
|
||||
onChange?.(file);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
@@ -6,11 +6,9 @@ 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 from "react";
|
||||
import React, { lazy } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog";
|
||||
import type ImportE2eKeysDialog from "../../../async-components/views/dialogs/security/ImportE2eKeysDialog";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
@@ -18,7 +16,7 @@ import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import SettingsSubsection, { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {}
|
||||
@@ -129,19 +127,15 @@ export default class CryptographyPanel extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
private onExportE2eKeysClicked = (): void => {
|
||||
Modal.createDialogAsync(
|
||||
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise<
|
||||
typeof ExportE2eKeysDialog
|
||||
>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
|
||||
{ matrixClient: this.context },
|
||||
);
|
||||
};
|
||||
|
||||
private onImportE2eKeysClicked = (): void => {
|
||||
Modal.createDialogAsync(
|
||||
import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog") as unknown as Promise<
|
||||
typeof ImportE2eKeysDialog
|
||||
>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog")),
|
||||
{ matrixClient: this.context },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 from "react";
|
||||
import React, { lazy } from "react";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
@@ -94,14 +94,12 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
|
||||
}
|
||||
|
||||
private onManage = async (): Promise<void> => {
|
||||
Modal.createDialogAsync(
|
||||
// @ts-ignore: TS doesn't seem to like the type of this now that it
|
||||
// has also been converted to TS as well, but I can't figure out why...
|
||||
import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog"),
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog")),
|
||||
{
|
||||
onFinished: () => {},
|
||||
},
|
||||
null,
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Layout } from "../../../settings/enums/Layout";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import Field from "../elements/Field";
|
||||
import { FontWatcher } from "../../../settings/watchers/FontWatcher";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import StyledRadioButton from "../elements/StyledRadioButton";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { ImageSize } from "../../../settings/enums/ImageSize";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
|
||||
interface IProps {
|
||||
// none
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import React, { JSX, useEffect, useState } from "react";
|
||||
import { Field, HelpMessage, InlineField, Label, RadioControl, Root, ToggleControl } from "@vector-im/compound-web";
|
||||
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from "../../../utils/pushRules/updatePushRuleActions";
|
||||
import { Caption } from "../typography/Caption";
|
||||
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import { doesRoomHaveUnreadMessages } from "../../../Unread";
|
||||
import SettingsFlag from "../elements/SettingsFlag";
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ 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, { ReactNode } from "react";
|
||||
import React, { lazy, ReactNode } from "react";
|
||||
import { CryptoEvent, BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
@@ -119,7 +118,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||
this.getUpdatedDiagnostics();
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const backupInfo = await cli.getKeyBackupVersion();
|
||||
const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
|
||||
const backupTrustInfo = backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined;
|
||||
|
||||
const activeBackupVersion = (await cli.getCrypto()?.getActiveSessionBackupVersion()) ?? null;
|
||||
@@ -170,10 +169,8 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||
}
|
||||
|
||||
private startNewBackup = (): void => {
|
||||
Modal.createDialogAsync(
|
||||
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise<
|
||||
typeof CreateKeyBackupDialog
|
||||
>,
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
|
||||
{
|
||||
onFinished: () => {
|
||||
this.loadBackupStatus();
|
||||
@@ -195,12 +192,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||
if (!proceed) return;
|
||||
this.setState({ loading: true });
|
||||
const versionToDelete = this.state.backupInfo!.version!;
|
||||
MatrixClientPeg.safeGet()
|
||||
.getCrypto()
|
||||
?.deleteKeyBackupVersion(versionToDelete)
|
||||
.then(() => {
|
||||
this.loadBackupStatus();
|
||||
});
|
||||
// deleteKeyBackupVersion fires a key backup status event
|
||||
// which will update the UI
|
||||
MatrixClientPeg.safeGet().getCrypto()?.deleteKeyBackupVersion(versionToDelete);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -212,7 +206,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
|
||||
private resetSecretStorage = async (): Promise<void> => {
|
||||
this.setState({ error: false });
|
||||
try {
|
||||
await accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
|
||||
await accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
|
||||
} catch (e) {
|
||||
logger.error("Error resetting secret storage", e);
|
||||
if (this.unmounted) return;
|
||||
|
||||
@@ -23,7 +23,7 @@ import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Alert } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "./shared/SettingsSubsection";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { ThirdPartyIdentifier } from "../../../AddThreepid";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
@@ -125,5 +125,3 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPersonalInfoSettings;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import Spinner from "../../elements/Spinner";
|
||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
|
||||
import DeviceDetails from "./DeviceDetails";
|
||||
import { DeviceExpandDetailsButton } from "./DeviceExpandDetailsButton";
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { ComponentProps } from "react";
|
||||
import { ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { Icon as CaretIcon } from "../../../../../res/img/feather-customised/dropdown-arrow.svg";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
|
||||
@@ -38,7 +38,7 @@ export const DeviceExpandDetailsButton = <T extends keyof JSX.IntrinsicElements>
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CaretIcon className="mx_DeviceExpandDetailsButton_icon" />
|
||||
<ChevronDownIcon className="mx_DeviceExpandDetailsButton_icon" />
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../shared/SettingsSubsection";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
|
||||
@@ -10,7 +10,7 @@ import React from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../shared/SettingsSubsection";
|
||||
import DeviceSecurityCard from "./DeviceSecurityCard";
|
||||
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
|
||||
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from "./filter";
|
||||
|
||||
@@ -18,7 +18,7 @@ import SettingsStore from "../../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../settings/UIFeature";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import SetIdServer from "../SetIdServer";
|
||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../shared/SettingsSubsection";
|
||||
import InlineTermsAgreement from "../../terms/InlineTermsAgreement";
|
||||
import { Service, ServicePolicyPair, startTermsFlow } from "../../../../Terms";
|
||||
import IdentityAuthClient from "../../../../IdentityAuthClient";
|
||||
@@ -190,5 +190,3 @@ export const DiscoverySettings: React.FC = () => {
|
||||
</SettingsSubsection>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverySettings;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { UserTab } from "../../dialogs/UserTab";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
import LabelledCheckbox from "../../elements/LabelledCheckbox";
|
||||
import { SettingsIndent } from "../shared/SettingsIndent";
|
||||
import SettingsSubsection, { SettingsSubsectionText } from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "../shared/SettingsSubsection";
|
||||
|
||||
function generalTabButton(content: string): JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,7 @@ import TagComposer from "../../elements/TagComposer";
|
||||
import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge";
|
||||
import { SettingsBanner } from "../shared/SettingsBanner";
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
import SettingsSubsection from "../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../shared/SettingsSubsection";
|
||||
import { NotificationPusherSettings } from "./NotificationPusherSettings";
|
||||
import SettingsFlag from "../../elements/SettingsFlag";
|
||||
|
||||
|
||||
@@ -65,5 +65,3 @@ export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
|
||||
{!legacy && <Separator />}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SettingsSubsection;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayl
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
||||
@@ -16,12 +16,12 @@ import dis from "../../../../../dispatcher/dispatcher";
|
||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../../settings/UIFeature";
|
||||
import UrlPreviewSettings from "../../../room_settings/UrlPreviewSettings";
|
||||
import AliasSettings from "../../../room_settings/AliasSettings";
|
||||
import PosthogTrackers from "../../../../../PosthogTrackers";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import { UrlPreviewSettings } from "../../../room_settings/UrlPreviewSettings";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
|
||||
@@ -25,7 +25,7 @@ import { UserTab } from "../../../dialogs/UserTab";
|
||||
import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
|
||||
@@ -127,12 +127,30 @@ interface IProps {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
||||
interface RolesRoomSettingsTabState {
|
||||
isRoomEncrypted: boolean;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export default class RolesRoomSettingsTab extends React.Component<IProps, RolesRoomSettingsTabState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public componentDidMount(): void {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isReady: false,
|
||||
isRoomEncrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async componentDidMount(): Promise<void> {
|
||||
this.context.on(RoomStateEvent.Update, this.onRoomStateUpdate);
|
||||
this.setState({
|
||||
isRoomEncrypted:
|
||||
(await this.context.getCrypto()?.isEncryptionEnabledInRoom(this.props.room.roomId)) || false,
|
||||
isReady: true,
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
@@ -416,7 +434,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
||||
.filter(Boolean);
|
||||
|
||||
// hide the power level selector for enabling E2EE if it the room is already encrypted
|
||||
if (client.isRoomEncrypted(this.props.room.roomId)) {
|
||||
if (this.state.isRoomEncrypted) {
|
||||
delete eventsLevels[EventType.RoomEncryption];
|
||||
}
|
||||
|
||||
@@ -458,17 +476,19 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
|
||||
{canChangeLevels && <AddPrivilegedUsers room={room} defaultUserLevel={defaultUserLevel} />}
|
||||
{mutedUsersSection}
|
||||
{bannedUsersSection}
|
||||
<SettingsFieldset
|
||||
legend={_t("room_settings|permissions|permissions_section")}
|
||||
description={
|
||||
isSpaceRoom
|
||||
? _t("room_settings|permissions|permissions_section_description_space")
|
||||
: _t("room_settings|permissions|permissions_section_description_room")
|
||||
}
|
||||
>
|
||||
{powerSelectors}
|
||||
{eventPowerSelectors}
|
||||
</SettingsFieldset>
|
||||
{this.state.isReady && (
|
||||
<SettingsFieldset
|
||||
legend={_t("room_settings|permissions|permissions_section")}
|
||||
description={
|
||||
isSpaceRoom
|
||||
? _t("room_settings|permissions|permissions_section_description_space")
|
||||
: _t("room_settings|permissions|permissions_section_description_room")
|
||||
}
|
||||
>
|
||||
{powerSelectors}
|
||||
{eventPowerSelectors}
|
||||
</SettingsFieldset>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsTab>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
EventType,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
|
||||
import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg";
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
@@ -53,7 +54,7 @@ interface IState {
|
||||
guestAccess: GuestAccess;
|
||||
history: HistoryVisibility;
|
||||
hasAliases: boolean;
|
||||
encrypted: boolean;
|
||||
encrypted: boolean | null;
|
||||
showAdvancedSection: boolean;
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
||||
HistoryVisibility.Shared,
|
||||
),
|
||||
hasAliases: false, // async loaded in componentDidMount
|
||||
encrypted: false, // async loaded in componentDidMount
|
||||
encrypted: null, // async loaded in componentDidMount
|
||||
showAdvancedSection: false,
|
||||
};
|
||||
}
|
||||
@@ -419,6 +420,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
||||
const client = this.context;
|
||||
const room = this.props.room;
|
||||
const isEncrypted = this.state.encrypted;
|
||||
const isEncryptionLoading = isEncrypted === null;
|
||||
const hasEncryptionPermission = room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, client);
|
||||
const isEncryptionForceDisabled = shouldForceDisableEncryption(client);
|
||||
const canEnableEncryption = !isEncrypted && !isEncryptionForceDisabled && hasEncryptionPermission;
|
||||
@@ -451,18 +453,23 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
|
||||
: _t("room_settings|security|encryption_permanent")
|
||||
}
|
||||
>
|
||||
<LabelledToggleSwitch
|
||||
value={isEncrypted}
|
||||
onChange={this.onEncryptionChange}
|
||||
label={_t("common|encrypted")}
|
||||
disabled={!canEnableEncryption}
|
||||
/>
|
||||
{isEncryptionForceDisabled && !isEncrypted && (
|
||||
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
|
||||
{isEncryptionLoading ? (
|
||||
<InlineSpinner />
|
||||
) : (
|
||||
<>
|
||||
<LabelledToggleSwitch
|
||||
value={isEncrypted}
|
||||
onChange={this.onEncryptionChange}
|
||||
label={_t("common|encrypted")}
|
||||
disabled={!canEnableEncryption}
|
||||
/>
|
||||
{isEncryptionForceDisabled && !isEncrypted && (
|
||||
<Caption>{_t("room_settings|security|encryption_forced")}</Caption>
|
||||
)}
|
||||
{encryptionSettings}
|
||||
</>
|
||||
)}
|
||||
{encryptionSettings}
|
||||
</SettingsFieldset>
|
||||
|
||||
{this.renderJoinRule()}
|
||||
{historySection}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { JoinRule, EventType, RoomState, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../../../languageHandler";
|
||||
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { ElementCall } from "../../../../../models/Call";
|
||||
import { useRoomState } from "../../../../../hooks/useRoomState";
|
||||
|
||||
@@ -22,9 +22,9 @@ import ErrorDialog, { extractErrorMessageFromError } from "../../../dialogs/Erro
|
||||
import ChangePassword from "../../ChangePassword";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
||||
import { SDKContext } from "../../../../../contexts/SDKContext";
|
||||
import UserPersonalInfoSettings from "../../UserPersonalInfoSettings";
|
||||
import { UserPersonalInfoSettings } from "../../UserPersonalInfoSettings";
|
||||
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IProps {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ThemeChoicePanel } from "../../ThemeChoicePanel";
|
||||
import ImageSizePanel from "../../ImageSizePanel";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import BugReportDialog from "../../../dialogs/BugReportDialog";
|
||||
import CopyableText from "../../../elements/CopyableText";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
|
||||
import ExternalLink from "../../../elements/ExternalLink";
|
||||
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { KeyboardShortcut } from "../../KeyboardShortcut";
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { SettingsSection } from "../../shared/SettingsSection";
|
||||
import SettingsSubsection from "../../shared/SettingsSubsection";
|
||||
import { SettingsSubsection } from "../../shared/SettingsSubsection";
|
||||
import { showLabsFlags } from "./LabsUserSettingsTab";
|
||||
|
||||
interface IKeyboardShortcutRowProps {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user