Merge branch 'develop' into hs/media-previews-server-config
This commit is contained in:
@@ -711,36 +711,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
case "copy_room":
|
||||
this.copyRoom(payload.room_id);
|
||||
break;
|
||||
case "reject_invite":
|
||||
Modal.createDialog(QuestionDialog, {
|
||||
title: _t("reject_invitation_dialog|title"),
|
||||
description: _t("reject_invitation_dialog|confirmation"),
|
||||
onFinished: (confirm) => {
|
||||
if (confirm) {
|
||||
// FIXME: controller shouldn't be loading a view :(
|
||||
const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner");
|
||||
|
||||
MatrixClientPeg.safeGet()
|
||||
.leave(payload.room_id)
|
||||
.then(
|
||||
() => {
|
||||
modal.close();
|
||||
if (this.state.currentRoomId === payload.room_id) {
|
||||
dis.dispatch({ action: Action.ViewHomePage });
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
modal.close();
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("reject_invitation_dialog|failed"),
|
||||
description: err.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "view_user_info":
|
||||
this.viewUser(payload.userId, payload.subAction);
|
||||
break;
|
||||
|
||||
@@ -134,6 +134,7 @@ import { onView3pidInvite } from "../../stores/right-panel/action-handlers";
|
||||
import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel";
|
||||
import { PinnedMessageBanner } from "../views/rooms/PinnedMessageBanner";
|
||||
import { ScopedRoomContextProvider, useScopedRoomContext } from "../../contexts/ScopedRoomContext";
|
||||
import { DeclineAndBlockInviteDialog } from "../views/dialogs/DeclineAndBlockInviteDialog";
|
||||
|
||||
const DEBUG = false;
|
||||
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
|
||||
@@ -1732,48 +1733,61 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
});
|
||||
};
|
||||
|
||||
private onRejectButtonClicked = (): void => {
|
||||
const roomId = this.getRoomId();
|
||||
if (!roomId) return;
|
||||
private onDeclineAndBlockButtonClicked = async (): Promise<void> => {
|
||||
if (!this.state.room || !this.context.client) return;
|
||||
const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, {
|
||||
roomName: this.state.room.name,
|
||||
}).finished;
|
||||
if (!shouldReject) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
this.context.client?.leave(roomId).then(
|
||||
() => {
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
logger.error(`Failed to reject invite: ${error}`);
|
||||
|
||||
const msg = error.message ? error.message : JSON.stringify(error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("room|failed_reject_invite"),
|
||||
description: msg,
|
||||
});
|
||||
const actions: Promise<unknown>[] = [];
|
||||
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (ignoreUser) {
|
||||
const myMember = this.state.room.getMember(this.context.client!.getSafeUserId());
|
||||
const inviteEvent = myMember!.events.member;
|
||||
const ignoredUsers = this.context.client.getIgnoredUsers();
|
||||
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
|
||||
actions.push(this.context.client.setIgnoredUsers(ignoredUsers));
|
||||
}
|
||||
|
||||
if (reportRoom !== false) {
|
||||
actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom));
|
||||
}
|
||||
|
||||
actions.push(this.context.client.leave(this.state.room.roomId));
|
||||
try {
|
||||
await Promise.all(actions);
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reject invite: ${error}`);
|
||||
|
||||
const msg = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
title: _t("room|failed_reject_invite"),
|
||||
description: msg,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onRejectAndIgnoreClick = async (): Promise<void> => {
|
||||
this.setState({
|
||||
rejecting: true,
|
||||
});
|
||||
|
||||
private onDeclineButtonClicked = async (): Promise<void> => {
|
||||
if (!this.state.room || !this.context.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const myMember = this.state.room!.getMember(this.context.client!.getSafeUserId());
|
||||
const inviteEvent = myMember!.events.member;
|
||||
const ignoredUsers = this.context.client!.getIgnoredUsers();
|
||||
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk
|
||||
await this.context.client!.setIgnoredUsers(ignoredUsers);
|
||||
|
||||
await this.context.client!.leave(this.state.roomId!);
|
||||
await this.context.client.leave(this.state.room.roomId);
|
||||
defaultDispatcher.dispatch({ action: Action.ViewHomePage });
|
||||
this.setState({
|
||||
rejecting: false,
|
||||
@@ -2126,7 +2140,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
|
||||
canPreview={false}
|
||||
error={this.state.roomLoadError}
|
||||
roomAlias={roomAlias}
|
||||
@@ -2154,7 +2168,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewCard
|
||||
room={this.state.room}
|
||||
onJoinButtonClicked={this.onJoinButtonClicked}
|
||||
onRejectButtonClicked={this.onRejectButtonClicked}
|
||||
onRejectButtonClicked={this.onDeclineButtonClicked}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
@@ -2196,8 +2210,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectButtonClicked}
|
||||
onRejectAndIgnoreClick={this.onRejectAndIgnoreClick}
|
||||
onDeclineClick={this.onDeclineButtonClicked}
|
||||
onDeclineAndBlockClick={this.onDeclineAndBlockButtonClicked}
|
||||
promptRejectionOptions={true}
|
||||
inviterName={inviterName}
|
||||
canPreview={false}
|
||||
joining={this.state.joining}
|
||||
@@ -2312,7 +2327,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<RoomPreviewBar
|
||||
onJoinClick={this.onJoinButtonClicked}
|
||||
onForgetClick={this.onForgetClick}
|
||||
onRejectClick={this.onRejectThreepidInviteButtonClicked}
|
||||
onDeclineClick={this.onRejectThreepidInviteButtonClicked}
|
||||
promptRejectionOptions={true}
|
||||
joining={this.state.joining}
|
||||
inviterName={inviterName}
|
||||
invitedEmail={invitedEmail}
|
||||
@@ -2350,7 +2366,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
onRejectButtonClicked={
|
||||
this.props.threepidInvite
|
||||
? this.onRejectThreepidInviteButtonClicked
|
||||
: this.onRejectButtonClicked
|
||||
: this.onDeclineButtonClicked
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
|
||||
import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import { useEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
|
||||
|
||||
/**
|
||||
* Provides information about a primary filter.
|
||||
@@ -119,6 +121,12 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
setRooms(newRooms);
|
||||
}, []);
|
||||
|
||||
// Reset filters when active space changes
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
|
||||
setPrimaryFilter(undefined);
|
||||
activateSecondaryFilter(SecondaryFilters.AllActivity);
|
||||
});
|
||||
|
||||
const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] =>
|
||||
array.filter((f) => f !== undefined) as FilterKey[];
|
||||
|
||||
|
||||
82
src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx
Normal file
82
src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ChangeEventHandler, useCallback, useState } from "react";
|
||||
import { Field, Label, Root } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const DeclineAndBlockInviteDialog: React.FunctionComponent<IProps> = ({ onFinished, roomName }) => {
|
||||
const [shouldReport, setShouldReport] = useState<boolean>(false);
|
||||
const [ignoreUser, setIgnoreUser] = useState<boolean>(false);
|
||||
|
||||
const [reportReason, setReportReason] = useState<string>("");
|
||||
const reportReasonChanged = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
|
||||
(e) => setReportReason(e.target.value),
|
||||
[setReportReason],
|
||||
);
|
||||
|
||||
const onCancel = useCallback(() => onFinished(false, false, false), [onFinished]);
|
||||
const onOk = useCallback(
|
||||
() => onFinished(true, ignoreUser, shouldReport ? reportReason : false),
|
||||
[onFinished, ignoreUser, shouldReport, reportReason],
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_DeclineAndBlockInviteDialog"
|
||||
onFinished={onCancel}
|
||||
title={_t("decline_invitation_dialog|title")}
|
||||
contentId="mx_Dialog_content"
|
||||
>
|
||||
<Root>
|
||||
<p>{_t("decline_invitation_dialog|confirm", { roomName })}</p>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("report_content|ignore_user")}
|
||||
onChange={setIgnoreUser}
|
||||
caption={_t("decline_invitation_dialog|ignore_user_help")}
|
||||
value={ignoreUser}
|
||||
/>
|
||||
<LabelledToggleSwitch
|
||||
label={_t("action|report_room")}
|
||||
onChange={setShouldReport}
|
||||
caption={_t("decline_invitation_dialog|report_room_description")}
|
||||
value={shouldReport}
|
||||
/>
|
||||
<Field name="report-reason" aria-disabled={!shouldReport}>
|
||||
<Label htmlFor="mx_DeclineAndBlockInviteDialog_reason">
|
||||
{_t("room_settings|permissions|ban_reason")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="mx_DeclineAndBlockInviteDialog_reason"
|
||||
className="mx_RoomReportTextArea"
|
||||
placeholder={_t("decline_invitation_dialog|reason_description")}
|
||||
rows={5}
|
||||
onChange={reportReasonChanged}
|
||||
value={shouldReport ? reportReason : ""}
|
||||
disabled={!shouldReport}
|
||||
/>
|
||||
</Field>
|
||||
<DialogButtons
|
||||
primaryButton={_t("action|decline_invite")}
|
||||
primaryButtonClass="danger"
|
||||
cancelButton={_t("action|cancel")}
|
||||
onPrimaryButtonClick={onOk}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Root>
|
||||
</BaseDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import BaseDialog from "../BaseDialog";
|
||||
import DialogButtons from "../../elements/DialogButtons";
|
||||
|
||||
interface IProps {
|
||||
onFinished: (success?: boolean) => void;
|
||||
}
|
||||
|
||||
export default class ConfirmDestroyCrossSigningDialog extends React.Component<IProps> {
|
||||
private onConfirm = (): void => {
|
||||
this.props.onFinished(true);
|
||||
};
|
||||
|
||||
private onDecline = (): void => {
|
||||
this.props.onFinished(false);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_ConfirmDestroyCrossSigningDialog"
|
||||
hasCancel={true}
|
||||
onFinished={this.props.onFinished}
|
||||
title={_t("encryption|destroy_cross_signing_dialog|title")}
|
||||
>
|
||||
<div className="mx_ConfirmDestroyCrossSigningDialog_content">
|
||||
<p>{_t("encryption|destroy_cross_signing_dialog|warning")}</p>
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("encryption|destroy_cross_signing_dialog|primary_button_text")}
|
||||
onPrimaryButtonClick={this.onConfirm}
|
||||
primaryButtonClass="danger"
|
||||
cancelButton={_t("action|cancel")}
|
||||
onCancel={this.onDecline}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { type FC, useId } from "react";
|
||||
import classNames from "classnames";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import ToggleSwitch from "./ToggleSwitch";
|
||||
import { Caption } from "../typography/Caption";
|
||||
@@ -35,41 +34,50 @@ interface IProps {
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
|
||||
private readonly id = `mx_LabelledToggleSwitch_${secureRandomString(12)}`;
|
||||
const LabelledToggleSwitch: FC<IProps> = ({
|
||||
label,
|
||||
caption,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
tooltip,
|
||||
toggleInFront,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}) => {
|
||||
// This is a minimal version of a SettingsFlag
|
||||
const generatedId = useId();
|
||||
const id = `mx_LabelledToggleSwitch_${generatedId}`;
|
||||
let firstPart = (
|
||||
<span className="mx_SettingsFlag_label">
|
||||
<div id={id}>{label}</div>
|
||||
{caption && <Caption id={`${id}_caption`}>{caption}</Caption>}
|
||||
</span>
|
||||
);
|
||||
let secondPart = (
|
||||
<ToggleSwitch
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
tooltip={tooltip}
|
||||
aria-labelledby={id}
|
||||
aria-describedby={caption ? `${id}_caption` : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
public render(): React.ReactNode {
|
||||
// This is a minimal version of a SettingsFlag
|
||||
const { label, caption } = this.props;
|
||||
let firstPart = (
|
||||
<span className="mx_SettingsFlag_label">
|
||||
<div id={this.id}>{label}</div>
|
||||
{caption && <Caption id={`${this.id}_caption`}>{caption}</Caption>}
|
||||
</span>
|
||||
);
|
||||
let secondPart = (
|
||||
<ToggleSwitch
|
||||
checked={this.props.value}
|
||||
disabled={this.props.disabled}
|
||||
onChange={this.props.onChange}
|
||||
tooltip={this.props.tooltip}
|
||||
aria-labelledby={this.id}
|
||||
aria-describedby={caption ? `${this.id}_caption` : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (this.props.toggleInFront) {
|
||||
[firstPart, secondPart] = [secondPart, firstPart];
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SettingsFlag", this.props.className, {
|
||||
mx_SettingsFlag_toggleInFront: this.props.toggleInFront,
|
||||
});
|
||||
return (
|
||||
<div data-testid={this.props["data-testid"]} className={classes}>
|
||||
{firstPart}
|
||||
{secondPart}
|
||||
</div>
|
||||
);
|
||||
if (toggleInFront) {
|
||||
[firstPart, secondPart] = [secondPart, firstPart];
|
||||
}
|
||||
}
|
||||
|
||||
const classes = classNames("mx_SettingsFlag", className, {
|
||||
mx_SettingsFlag_toggleInFront: toggleInFront,
|
||||
});
|
||||
return (
|
||||
<div data-testid={testId} className={classes}>
|
||||
{firstPart}
|
||||
{secondPart}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelledToggleSwitch;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type RoomPreviewOpts,
|
||||
RoomViewLifecycle,
|
||||
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
@@ -90,12 +91,18 @@ interface IProps {
|
||||
roomAlias?: string;
|
||||
|
||||
onJoinClick?(): void;
|
||||
onRejectClick?(): void;
|
||||
onRejectAndIgnoreClick?(): void;
|
||||
onDeclineClick?(): void;
|
||||
onDeclineAndBlockClick?(): void;
|
||||
onForgetClick?(): void;
|
||||
|
||||
canAskToJoinAndMembershipIsLeave?: boolean;
|
||||
promptAskToJoin?: boolean;
|
||||
|
||||
/**
|
||||
* If true, this will prompt for additional safety options
|
||||
* like reporting an invite or ignoring the user.
|
||||
*/
|
||||
promptRejectionOptions?: boolean;
|
||||
knocked?: boolean;
|
||||
onSubmitAskToJoin?(reason?: string): void;
|
||||
onCancelAskToJoin?(): void;
|
||||
@@ -313,6 +320,8 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
let primaryActionLabel: string | undefined;
|
||||
let secondaryActionHandler: (() => void) | undefined;
|
||||
let secondaryActionLabel: string | undefined;
|
||||
let dangerActionHandler: (() => void) | undefined;
|
||||
let dangerActionLabel: string | undefined;
|
||||
let footer: JSX.Element | undefined;
|
||||
const extraComponents: JSX.Element[] = [];
|
||||
|
||||
@@ -549,16 +558,11 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
primaryActionHandler = this.props.onJoinClick;
|
||||
secondaryActionLabel = _t("action|reject");
|
||||
secondaryActionHandler = this.props.onRejectClick;
|
||||
secondaryActionLabel = _t("action|decline");
|
||||
secondaryActionHandler = this.props.onDeclineClick;
|
||||
dangerActionLabel = _t("action|decline_and_block");
|
||||
dangerActionHandler = this.props.onDeclineAndBlockClick;
|
||||
|
||||
if (this.props.onRejectAndIgnoreClick) {
|
||||
extraComponents.push(
|
||||
<AccessibleButton kind="secondary" onClick={this.props.onRejectAndIgnoreClick} key="ignore">
|
||||
{_t("room|invite_reject_ignore")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageCase.ViewingRoom: {
|
||||
@@ -691,6 +695,15 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
);
|
||||
}
|
||||
|
||||
let dangerActionButton;
|
||||
if (dangerActionHandler) {
|
||||
dangerActionButton = (
|
||||
<Button destructive kind="tertiary" onClick={dangerActionHandler}>
|
||||
{dangerActionLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const isPanel = this.props.canPreview;
|
||||
|
||||
const classes = classNames("mx_RoomPreviewBar", `mx_RoomPreviewBar_${messageCase}`, {
|
||||
@@ -701,6 +714,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
// ensure correct tab order for both views
|
||||
const actions = isPanel ? (
|
||||
<>
|
||||
{dangerActionButton}
|
||||
{secondaryButton}
|
||||
{extraComponents}
|
||||
{primaryButton}
|
||||
@@ -710,6 +724,7 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
||||
{primaryButton}
|
||||
{extraComponents}
|
||||
{secondaryButton}
|
||||
{dangerActionButton}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
|
||||
onRejectButtonClicked();
|
||||
}}
|
||||
>
|
||||
{_t("action|reject")}
|
||||
{_t("action|decline")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { ClientEvent, type EmptyObject, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import InteractiveAuthDialog from "../dialogs/InteractiveAuthDialog";
|
||||
import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroyCrossSigningDialog";
|
||||
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
|
||||
import { accessSecretStorage, withSecretStorageKeyCache } from "../../../SecurityManager";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
|
||||
interface IState {
|
||||
error: boolean;
|
||||
crossSigningPublicKeysOnDevice?: boolean;
|
||||
crossSigningPrivateKeysInStorage?: boolean;
|
||||
masterPrivateKeyCached?: boolean;
|
||||
selfSigningPrivateKeyCached?: boolean;
|
||||
userSigningPrivateKeyCached?: boolean;
|
||||
homeserverSupportsCrossSigning?: boolean;
|
||||
crossSigningReady?: boolean;
|
||||
}
|
||||
|
||||
export default class CrossSigningPanel extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
cli.on(ClientEvent.AccountData, this.onAccountData);
|
||||
cli.on(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
|
||||
cli.on(CryptoEvent.KeysChanged, this.onStatusChanged);
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (!cli) return;
|
||||
cli.removeListener(ClientEvent.AccountData, this.onAccountData);
|
||||
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onStatusChanged);
|
||||
cli.removeListener(CryptoEvent.KeysChanged, this.onStatusChanged);
|
||||
}
|
||||
|
||||
private onAccountData = (event: MatrixEvent): void => {
|
||||
const type = event.getType();
|
||||
if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) {
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
};
|
||||
|
||||
private onBootstrapClick = (): void => {
|
||||
if (this.state.crossSigningPrivateKeysInStorage) {
|
||||
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
|
||||
} else {
|
||||
// Trigger the flow to set up secure backup, which is what this will do when in
|
||||
// the appropriate state.
|
||||
accessSecretStorage();
|
||||
}
|
||||
};
|
||||
|
||||
private onStatusChanged = (): void => {
|
||||
this.getUpdatedStatus();
|
||||
};
|
||||
|
||||
private async getUpdatedStatus(): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
const crossSigningStatus = await crypto.getCrossSigningStatus();
|
||||
const crossSigningPublicKeysOnDevice = crossSigningStatus.publicKeysOnDevice;
|
||||
const crossSigningPrivateKeysInStorage = crossSigningStatus.privateKeysInSecretStorage;
|
||||
const masterPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.masterKey;
|
||||
const selfSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.selfSigningKey;
|
||||
const userSigningPrivateKeyCached = crossSigningStatus.privateKeysCachedLocally.userSigningKey;
|
||||
const homeserverSupportsCrossSigning =
|
||||
await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
|
||||
const crossSigningReady = await crypto.isCrossSigningReady();
|
||||
|
||||
this.setState({
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
homeserverSupportsCrossSigning,
|
||||
crossSigningReady,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the user's cross-signing keys.
|
||||
*/
|
||||
private async resetCrossSigning(): Promise<void> {
|
||||
this.setState({ error: false });
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
await withSecretStorageKeyCache(async () => {
|
||||
await cli.getCrypto()!.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
|
||||
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
|
||||
title: _t("encryption|bootstrap_title"),
|
||||
matrixClient: cli,
|
||||
makeRequest,
|
||||
});
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
throw new Error("Cross-signing key upload auth canceled");
|
||||
}
|
||||
},
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
this.setState({ error: true });
|
||||
logger.error("Error bootstrapping cross-signing", e);
|
||||
}
|
||||
if (this.unmounted) return;
|
||||
this.getUpdatedStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for when the user clicks the "reset cross signing" button.
|
||||
*
|
||||
* Shows a confirmation dialog, and then does the reset if confirmed.
|
||||
*/
|
||||
private onResetCrossSigningClick = (): void => {
|
||||
Modal.createDialog(ConfirmDestroyCrossSigningDialog, {
|
||||
onFinished: async (act) => {
|
||||
if (!act) return;
|
||||
this.resetCrossSigning();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const {
|
||||
error,
|
||||
crossSigningPublicKeysOnDevice,
|
||||
crossSigningPrivateKeysInStorage,
|
||||
masterPrivateKeyCached,
|
||||
selfSigningPrivateKeyCached,
|
||||
userSigningPrivateKeyCached,
|
||||
homeserverSupportsCrossSigning,
|
||||
crossSigningReady,
|
||||
} = this.state;
|
||||
|
||||
let errorSection;
|
||||
if (error) {
|
||||
errorSection = <div className="error">{error.toString()}</div>;
|
||||
}
|
||||
|
||||
let summarisedStatus;
|
||||
if (homeserverSupportsCrossSigning === undefined) {
|
||||
summarisedStatus = <Spinner />;
|
||||
} else if (!homeserverSupportsCrossSigning) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_unsupported")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningReady && crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
✅ {_t("encryption|cross_signing_ready")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningReady && !crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
⚠️ {_t("encryption|cross_signing_ready_no_backup")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (crossSigningPrivateKeysInStorage) {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_untrusted")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else {
|
||||
summarisedStatus = (
|
||||
<SettingsSubsectionText data-testid="summarised-status">
|
||||
{_t("encryption|cross_signing_not_ready")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
}
|
||||
|
||||
const keysExistAnywhere =
|
||||
crossSigningPublicKeysOnDevice ||
|
||||
crossSigningPrivateKeysInStorage ||
|
||||
masterPrivateKeyCached ||
|
||||
selfSigningPrivateKeyCached ||
|
||||
userSigningPrivateKeyCached;
|
||||
const keysExistEverywhere =
|
||||
crossSigningPublicKeysOnDevice &&
|
||||
crossSigningPrivateKeysInStorage &&
|
||||
masterPrivateKeyCached &&
|
||||
selfSigningPrivateKeyCached &&
|
||||
userSigningPrivateKeyCached;
|
||||
|
||||
const actions: JSX.Element[] = [];
|
||||
|
||||
// TODO: determine how better to expose this to users in addition to prompts at login/toast
|
||||
if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
|
||||
let buttonCaption = _t("encryption|set_up_toast_title");
|
||||
if (crossSigningPrivateKeysInStorage) {
|
||||
buttonCaption = _t("encryption|verify_toast_title");
|
||||
}
|
||||
actions.push(
|
||||
<AccessibleButton key="setup" kind="primary_outline" onClick={this.onBootstrapClick}>
|
||||
{buttonCaption}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
if (keysExistAnywhere) {
|
||||
actions.push(
|
||||
<AccessibleButton key="reset" kind="danger_outline" onClick={this.onResetCrossSigningClick}>
|
||||
{_t("action|reset")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
let actionRow;
|
||||
if (actions.length) {
|
||||
actionRow = <div className="mx_CrossSigningPanel_buttonRow">{actions}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{summarisedStatus}
|
||||
<details>
|
||||
<summary className="mx_CrossSigningPanel_advanced">{_t("common|advanced")}</summary>
|
||||
<table className="mx_CrossSigningPanel_statusList">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_public_keys")}</th>
|
||||
<td>
|
||||
{crossSigningPublicKeysOnDevice
|
||||
? _t("settings|security|cross_signing_in_memory")
|
||||
: _t("settings|security|cross_signing_not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_private_keys")}</th>
|
||||
<td>
|
||||
{crossSigningPrivateKeysInStorage
|
||||
? _t("settings|security|cross_signing_in_4s")
|
||||
: _t("settings|security|cross_signing_not_in_4s")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_master_private_Key")}</th>
|
||||
<td>
|
||||
{masterPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_self_signing_private_key")}</th>
|
||||
<td>
|
||||
{selfSigningPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_user_signing_private_key")}</th>
|
||||
<td>
|
||||
{userSigningPrivateKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|cross_signing_homeserver_support")}</th>
|
||||
<td>
|
||||
{homeserverSupportsCrossSigning
|
||||
? _t("settings|security|cross_signing_homeserver_support_exists")
|
||||
: _t("settings|security|cross_signing_not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{errorSection}
|
||||
{actionRow}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, lazy } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
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 MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
interface IState {
|
||||
/** The device's base64-encoded Ed25519 identity key, or:
|
||||
*
|
||||
* * `undefined`: not yet loaded
|
||||
* * `null`: encryption is not supported (or the crypto stack was not correctly initialized)
|
||||
*/
|
||||
deviceIdentityKey: string | undefined | null;
|
||||
}
|
||||
|
||||
export default class CryptographyPanel extends React.Component<EmptyObject, IState> {
|
||||
public static contextType = MatrixClientContext;
|
||||
declare public context: React.ContextType<typeof MatrixClientContext>;
|
||||
|
||||
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props);
|
||||
|
||||
if (!context.getCrypto()) {
|
||||
this.state = { deviceIdentityKey: null };
|
||||
} else {
|
||||
this.state = { deviceIdentityKey: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.state.deviceIdentityKey === undefined) {
|
||||
this.context
|
||||
.getCrypto()
|
||||
?.getOwnDeviceKeys()
|
||||
.then((keys) => {
|
||||
this.setState({ deviceIdentityKey: keys.ed25519 });
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error(`CryptographyPanel: Error fetching own device keys: ${e}`);
|
||||
this.setState({ deviceIdentityKey: null });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const client = this.context;
|
||||
const deviceId = client.deviceId;
|
||||
let identityKey = this.state.deviceIdentityKey;
|
||||
if (identityKey === undefined) {
|
||||
// Should show a spinner here really, but since this will be very transitional, I can't be doing with the
|
||||
// necessary styling.
|
||||
identityKey = "...";
|
||||
} else if (identityKey === null) {
|
||||
identityKey = _t("encryption|not_supported");
|
||||
} else {
|
||||
identityKey = FormattingUtils.formatCryptoKey(identityKey);
|
||||
}
|
||||
|
||||
let importExportButtons: JSX.Element | undefined;
|
||||
if (client.getCrypto()) {
|
||||
importExportButtons = (
|
||||
<div className="mx_CryptographyPanel_importExportButtons">
|
||||
<AccessibleButton kind="primary_outline" onClick={this.onExportE2eKeysClicked}>
|
||||
{_t("settings|security|export_megolm_keys")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary_outline" onClick={this.onImportE2eKeysClicked}>
|
||||
{_t("settings|security|import_megolm_keys")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let noSendUnverifiedSetting: JSX.Element | undefined;
|
||||
if (SettingsStore.canSetValue("blacklistUnverifiedDevices", null, SettingLevel.DEVICE)) {
|
||||
noSendUnverifiedSetting = (
|
||||
<SettingsFlag
|
||||
name="blacklistUnverifiedDevices"
|
||||
level={SettingLevel.DEVICE}
|
||||
onChange={this.updateBlacklistDevicesFlag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSubsection heading={_t("settings|security|cryptography_section")}>
|
||||
<SettingsSubsectionText>
|
||||
<table className="mx_CryptographyPanel_sessionInfo">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|session_id")}</th>
|
||||
<td>
|
||||
<code>{deviceId}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|session_key")}</th>
|
||||
<td>
|
||||
<code>
|
||||
<strong>{identityKey}</strong>
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</SettingsSubsectionText>
|
||||
{importExportButtons}
|
||||
{noSendUnverifiedSetting}
|
||||
</SettingsSubsection>
|
||||
);
|
||||
}
|
||||
|
||||
private onExportE2eKeysClicked = (): void => {
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog")),
|
||||
{ matrixClient: this.context },
|
||||
);
|
||||
};
|
||||
|
||||
private onImportE2eKeysClicked = (): void => {
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog")),
|
||||
{ matrixClient: this.context },
|
||||
);
|
||||
};
|
||||
|
||||
private updateBlacklistDevicesFlag = (checked: boolean): void => {
|
||||
const crypto = this.context.getCrypto();
|
||||
if (crypto) crypto.globalBlacklistUnverifiedDevices = checked;
|
||||
};
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { lazy, type JSX, type ReactNode } from "react";
|
||||
import { CryptoEvent, type BackupTrustInfo, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import Modal from "../../../Modal";
|
||||
import { isSecureBackupRequired } from "../../../utils/WellKnownUtils";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import RestoreKeyBackupDialog from "../dialogs/security/RestoreKeyBackupDialog";
|
||||
import { accessSecretStorage } from "../../../SecurityManager";
|
||||
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
|
||||
|
||||
interface IState {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
backupKeyStored: boolean | null;
|
||||
backupKeyCached: boolean | null;
|
||||
backupKeyWellFormed: boolean | null;
|
||||
secretStorageKeyInAccount: boolean | null;
|
||||
secretStorageReady: boolean | null;
|
||||
|
||||
/** Information on the current key backup version, as returned by the server.
|
||||
*
|
||||
* `null` could mean any of:
|
||||
* * we haven't yet requested the data from the server.
|
||||
* * we were unable to reach the server.
|
||||
* * the server returned key backup version data we didn't understand or was malformed.
|
||||
* * there is actually no backup on the server.
|
||||
*/
|
||||
backupInfo: KeyBackupInfo | null;
|
||||
|
||||
/**
|
||||
* Information on whether the backup in `backupInfo` is correctly signed, and whether we have the right key to
|
||||
* decrypt it.
|
||||
*
|
||||
* `undefined` if `backupInfo` is null, or if crypto is not enabled in the client.
|
||||
*/
|
||||
backupTrustInfo: BackupTrustInfo | undefined;
|
||||
|
||||
/**
|
||||
* If key backup is currently enabled, the backup version we are backing up to.
|
||||
*/
|
||||
activeBackupVersion: string | null;
|
||||
|
||||
/**
|
||||
* Number of sessions remaining to be backed up. `null` if we have no information on this.
|
||||
*/
|
||||
sessionsRemaining: number | null;
|
||||
}
|
||||
|
||||
export default class SecureBackupPanel extends React.PureComponent<EmptyObject, IState> {
|
||||
private unmounted = false;
|
||||
|
||||
public constructor(props: EmptyObject) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: false,
|
||||
backupKeyStored: null,
|
||||
backupKeyCached: null,
|
||||
backupKeyWellFormed: null,
|
||||
secretStorageKeyInAccount: null,
|
||||
secretStorageReady: null,
|
||||
backupInfo: null,
|
||||
backupTrustInfo: undefined,
|
||||
activeBackupVersion: null,
|
||||
sessionsRemaining: null,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
this.loadBackupStatus();
|
||||
|
||||
MatrixClientPeg.safeGet().on(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus);
|
||||
MatrixClientPeg.safeGet().on(CryptoEvent.KeyBackupSessionsRemaining, this.onKeyBackupSessionsRemaining);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
|
||||
if (MatrixClientPeg.get()) {
|
||||
MatrixClientPeg.get()!.removeListener(CryptoEvent.KeyBackupStatus, this.onKeyBackupStatus);
|
||||
MatrixClientPeg.get()!.removeListener(
|
||||
CryptoEvent.KeyBackupSessionsRemaining,
|
||||
this.onKeyBackupSessionsRemaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyBackupSessionsRemaining = (sessionsRemaining: number): void => {
|
||||
this.setState({
|
||||
sessionsRemaining,
|
||||
});
|
||||
};
|
||||
|
||||
private onKeyBackupStatus = (): void => {
|
||||
// This just loads the current backup status rather than forcing
|
||||
// a re-check otherwise we risk causing infinite loops
|
||||
this.loadBackupStatus();
|
||||
};
|
||||
|
||||
private async loadBackupStatus(): Promise<void> {
|
||||
this.setState({ loading: true });
|
||||
this.getUpdatedDiagnostics();
|
||||
try {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const backupInfo = (await cli.getCrypto()?.getKeyBackupInfo()) ?? null;
|
||||
const backupTrustInfo = backupInfo ? await cli.getCrypto()?.isKeyBackupTrusted(backupInfo) : undefined;
|
||||
|
||||
const activeBackupVersion = (await cli.getCrypto()?.getActiveSessionBackupVersion()) ?? null;
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: false,
|
||||
backupInfo,
|
||||
backupTrustInfo,
|
||||
activeBackupVersion,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.log("Unable to fetch key backup status", e);
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: true,
|
||||
backupInfo: null,
|
||||
backupTrustInfo: undefined,
|
||||
activeBackupVersion: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getUpdatedDiagnostics(): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const crypto = cli.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
const secretStorage = cli.secretStorage;
|
||||
|
||||
const backupKeyStored = !!(await cli.isKeyBackupKeyStored());
|
||||
const backupKeyFromCache = await crypto.getSessionBackupPrivateKey();
|
||||
const backupKeyCached = !!backupKeyFromCache;
|
||||
const backupKeyWellFormed = backupKeyFromCache instanceof Uint8Array;
|
||||
const secretStorageKeyInAccount = await secretStorage.hasKey();
|
||||
const secretStorageReady = await crypto.isSecretStorageReady();
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
backupKeyStored,
|
||||
backupKeyCached,
|
||||
backupKeyWellFormed,
|
||||
secretStorageKeyInAccount,
|
||||
secretStorageReady,
|
||||
});
|
||||
}
|
||||
|
||||
private startNewBackup = (): void => {
|
||||
Modal.createDialog(
|
||||
lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
|
||||
{
|
||||
onFinished: () => {
|
||||
this.loadBackupStatus();
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
/* priority = */ false,
|
||||
/* static = */ true,
|
||||
);
|
||||
};
|
||||
|
||||
private deleteBackup = (): void => {
|
||||
Modal.createDialog(QuestionDialog, {
|
||||
title: _t("settings|security|delete_backup"),
|
||||
description: _t("settings|security|delete_backup_confirm_description"),
|
||||
button: _t("settings|security|delete_backup"),
|
||||
danger: true,
|
||||
onFinished: (proceed) => {
|
||||
if (!proceed) return;
|
||||
this.setState({ loading: true });
|
||||
const versionToDelete = this.state.backupInfo!.version!;
|
||||
// deleteKeyBackupVersion fires a key backup status event
|
||||
// which will update the UI
|
||||
MatrixClientPeg.safeGet().getCrypto()?.deleteKeyBackupVersion(versionToDelete);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private restoreBackup = async (): Promise<void> => {
|
||||
Modal.createDialog(RestoreKeyBackupDialog, undefined, undefined, /* priority = */ false, /* static = */ true);
|
||||
};
|
||||
|
||||
private resetSecretStorage = async (): Promise<void> => {
|
||||
this.setState({ error: false });
|
||||
try {
|
||||
await accessSecretStorage(async (): Promise<void> => {}, { forceReset: true });
|
||||
} catch (e) {
|
||||
logger.error("Error resetting secret storage", e);
|
||||
if (this.unmounted) return;
|
||||
this.setState({ error: true });
|
||||
}
|
||||
if (this.unmounted) return;
|
||||
this.loadBackupStatus();
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
backupKeyStored,
|
||||
backupKeyCached,
|
||||
backupKeyWellFormed,
|
||||
secretStorageKeyInAccount,
|
||||
secretStorageReady,
|
||||
backupInfo,
|
||||
backupTrustInfo,
|
||||
sessionsRemaining,
|
||||
} = this.state;
|
||||
|
||||
let statusDescription: JSX.Element;
|
||||
let extraDetailsTableRows: JSX.Element | undefined;
|
||||
let extraDetails: JSX.Element | undefined;
|
||||
const actions: JSX.Element[] = [];
|
||||
if (error) {
|
||||
statusDescription = (
|
||||
<SettingsSubsectionText className="error">
|
||||
{_t("settings|security|error_loading_key_backup_status")}
|
||||
</SettingsSubsectionText>
|
||||
);
|
||||
} else if (loading) {
|
||||
statusDescription = <Spinner />;
|
||||
} else if (backupInfo) {
|
||||
let restoreButtonCaption = _t("settings|security|restore_key_backup");
|
||||
|
||||
if (this.state.activeBackupVersion !== null) {
|
||||
statusDescription = (
|
||||
<SettingsSubsectionText>✅ {_t("settings|security|key_backup_active")}</SettingsSubsectionText>
|
||||
);
|
||||
} else {
|
||||
statusDescription = (
|
||||
<>
|
||||
<SettingsSubsectionText>
|
||||
{_t("settings|security|key_backup_inactive", {}, { b: (sub) => <strong>{sub}</strong> })}
|
||||
</SettingsSubsectionText>
|
||||
<SettingsSubsectionText>
|
||||
{_t("settings|security|key_backup_connect_prompt")}
|
||||
</SettingsSubsectionText>
|
||||
</>
|
||||
);
|
||||
restoreButtonCaption = _t("settings|security|key_backup_connect");
|
||||
}
|
||||
|
||||
let uploadStatus: ReactNode;
|
||||
if (sessionsRemaining === null) {
|
||||
// No upload status to show when backup disabled.
|
||||
uploadStatus = "";
|
||||
} else if (sessionsRemaining > 0) {
|
||||
uploadStatus = (
|
||||
<div>
|
||||
{_t("settings|security|key_backup_in_progress", { sessionsRemaining })} <br />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
uploadStatus = (
|
||||
<div>
|
||||
{_t("settings|security|key_backup_complete")} <br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let trustedLocally: string | undefined;
|
||||
if (backupTrustInfo?.matchesDecryptionKey) {
|
||||
trustedLocally = _t("settings|security|key_backup_can_be_restored");
|
||||
}
|
||||
|
||||
extraDetailsTableRows = (
|
||||
<>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|key_backup_latest_version")}</th>
|
||||
<td>
|
||||
{backupInfo.version} ({_t("settings|security|key_backup_algorithm")}{" "}
|
||||
<code>{backupInfo.algorithm}</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|key_backup_active_version")}</th>
|
||||
<td>
|
||||
{this.state.activeBackupVersion === null
|
||||
? _t("settings|security|key_backup_active_version_none")
|
||||
: this.state.activeBackupVersion}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
|
||||
extraDetails = (
|
||||
<>
|
||||
{uploadStatus}
|
||||
<div>{trustedLocally}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
actions.push(
|
||||
<AccessibleButton key="restore" kind="primary_outline" onClick={this.restoreBackup}>
|
||||
{restoreButtonCaption}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
|
||||
if (!isSecureBackupRequired(MatrixClientPeg.safeGet())) {
|
||||
actions.push(
|
||||
<AccessibleButton key="delete" kind="danger_outline" onClick={this.deleteBackup}>
|
||||
{_t("settings|security|delete_backup")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
statusDescription = (
|
||||
<>
|
||||
<SettingsSubsectionText>
|
||||
{_t(
|
||||
"settings|security|key_backup_inactive_warning",
|
||||
{},
|
||||
{ b: (sub) => <strong>{sub}</strong> },
|
||||
)}
|
||||
</SettingsSubsectionText>
|
||||
<SettingsSubsectionText>{_t("encryption|setup_secure_backup|explainer")}</SettingsSubsectionText>
|
||||
</>
|
||||
);
|
||||
actions.push(
|
||||
<AccessibleButton key="setup" kind="primary_outline" onClick={this.startNewBackup}>
|
||||
{_t("encryption|setup_secure_backup|title")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
if (secretStorageKeyInAccount) {
|
||||
actions.push(
|
||||
<AccessibleButton key="reset" kind="danger_outline" onClick={this.resetSecretStorage}>
|
||||
{_t("action|reset")}
|
||||
</AccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
let backupKeyWellFormedText = "";
|
||||
if (backupKeyCached) {
|
||||
backupKeyWellFormedText = ", ";
|
||||
if (backupKeyWellFormed) {
|
||||
backupKeyWellFormedText += _t("settings|security|backup_key_well_formed");
|
||||
} else {
|
||||
backupKeyWellFormedText += _t("settings|security|backup_key_unexpected_type");
|
||||
}
|
||||
}
|
||||
|
||||
let actionRow: JSX.Element | undefined;
|
||||
if (actions.length) {
|
||||
actionRow = <div className="mx_SecureBackupPanel_buttonRow">{actions}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSubsectionText>{_t("settings|security|backup_keys_description")}</SettingsSubsectionText>
|
||||
{statusDescription}
|
||||
<details>
|
||||
<summary className="mx_SecureBackupPanel_advanced">{_t("common|advanced")}</summary>
|
||||
<table className="mx_SecureBackupPanel_statusList">
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|backup_key_stored_status")}</th>
|
||||
<td>
|
||||
{backupKeyStored === true
|
||||
? _t("settings|security|cross_signing_in_4s")
|
||||
: _t("settings|security|cross_signing_not_stored")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|backup_key_cached_status")}</th>
|
||||
<td>
|
||||
{backupKeyCached
|
||||
? _t("settings|security|cross_signing_cached")
|
||||
: _t("settings|security|cross_signing_not_cached")}
|
||||
{backupKeyWellFormedText}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|4s_public_key_status")}</th>
|
||||
<td>
|
||||
{secretStorageKeyInAccount
|
||||
? _t("settings|security|4s_public_key_in_account_data")
|
||||
: _t("settings|security|cross_signing_not_found")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{_t("settings|security|secret_storage_status")}</th>
|
||||
<td>
|
||||
{secretStorageReady
|
||||
? _t("settings|security|secret_storage_ready")
|
||||
: _t("settings|security|secret_storage_not_ready")}
|
||||
</td>
|
||||
</tr>
|
||||
{extraDetailsTableRows}
|
||||
</table>
|
||||
{extraDetails}
|
||||
</details>
|
||||
{actionRow}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,10 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import dis from "../../../../../dispatcher/dispatcher";
|
||||
import { SettingLevel } from "../../../../../settings/SettingLevel";
|
||||
import SecureBackupPanel from "../../SecureBackupPanel";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../../../settings/UIFeature";
|
||||
import { type ActionPayload } from "../../../../../dispatcher/payloads";
|
||||
import CryptographyPanel from "../../CryptographyPanel";
|
||||
import SettingsFlag from "../../../elements/SettingsFlag";
|
||||
import CrossSigningPanel from "../../CrossSigningPanel";
|
||||
import EventIndexPanel from "../../EventIndexPanel";
|
||||
import InlineSpinner from "../../../elements/InlineSpinner";
|
||||
import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
|
||||
@@ -42,21 +39,20 @@ interface IIgnoredUserProps {
|
||||
inProgress: boolean;
|
||||
}
|
||||
|
||||
const DehydratedDeviceStatus: React.FC = () => {
|
||||
const SecureBackup: React.FC = () => {
|
||||
const { dehydratedDeviceId } = useOwnDevices();
|
||||
if (!dehydratedDeviceId) return null;
|
||||
|
||||
if (dehydratedDeviceId) {
|
||||
return (
|
||||
return (
|
||||
<SettingsSubsection heading={_t("common|secure_backup")}>
|
||||
<div className="mx_SettingsSubsection_content">
|
||||
<div className="mx_SettingsFlag_label">{_t("settings|security|dehydrated_device_enabled")}</div>
|
||||
<div className="mx_SettingsSubsection_text">
|
||||
{_t("settings|security|dehydrated_device_description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
</SettingsSubsection>
|
||||
);
|
||||
};
|
||||
|
||||
export class IgnoredUser extends React.Component<IIgnoredUserProps> {
|
||||
@@ -67,7 +63,7 @@ export class IgnoredUser extends React.Component<IIgnoredUserProps> {
|
||||
public render(): React.ReactNode {
|
||||
const id = `mx_SecurityUserSettingsTab_ignoredUser_${this.props.userId}`;
|
||||
return (
|
||||
<div className="mx_SecurityUserSettingsTab_ignoredUser">
|
||||
<li className="mx_SecurityUserSettingsTab_ignoredUser" aria-label={this.props.userId}>
|
||||
<AccessibleButton
|
||||
onClick={this.onUnignoreClicked}
|
||||
kind="primary_sm"
|
||||
@@ -77,7 +73,7 @@ export class IgnoredUser extends React.Component<IIgnoredUserProps> {
|
||||
{_t("action|unignore")}
|
||||
</AccessibleButton>
|
||||
<span id={id}>{this.props.userId}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,23 +230,34 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||
|
||||
private renderIgnoredUsers(): JSX.Element {
|
||||
const { waitingUnignored, ignoredUserIds } = this.state;
|
||||
|
||||
const userIds = !ignoredUserIds?.length
|
||||
? _t("settings|security|ignore_users_empty")
|
||||
: ignoredUserIds.map((u) => {
|
||||
return (
|
||||
<IgnoredUser
|
||||
userId={u}
|
||||
onUnignored={this.onUserUnignored}
|
||||
key={u}
|
||||
inProgress={waitingUnignored.includes(u)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (!ignoredUserIds?.length) {
|
||||
return (
|
||||
<SettingsSubsection heading={_t("settings|security|ignore_users_section")}>
|
||||
<SettingsSubsectionText>{_t("settings|security|ignore_users_empty")}</SettingsSubsectionText>
|
||||
</SettingsSubsection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSubsection heading={_t("settings|security|ignore_users_section")}>
|
||||
<SettingsSubsectionText>{userIds}</SettingsSubsectionText>
|
||||
<SettingsSubsection
|
||||
id="mx_SecurityUserSettingsTab_ignoredUsersHeading"
|
||||
heading={_t("settings|security|ignore_users_section")}
|
||||
>
|
||||
<SettingsSubsectionText>
|
||||
<ul
|
||||
aria-label={_t("settings|security|ignore_users_section")}
|
||||
className="mx_SecurityUserSettingsTab_ignoredUsers"
|
||||
>
|
||||
{ignoredUserIds.map((u) => (
|
||||
<IgnoredUser
|
||||
userId={u}
|
||||
onUnignored={this.onUserUnignored}
|
||||
key={u}
|
||||
inProgress={waitingUnignored.includes(u)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsSubsectionText>
|
||||
</SettingsSubsection>
|
||||
);
|
||||
}
|
||||
@@ -286,12 +293,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const secureBackup = (
|
||||
<SettingsSubsection heading={_t("common|secure_backup")}>
|
||||
<SecureBackupPanel />
|
||||
<DehydratedDeviceStatus />
|
||||
</SettingsSubsection>
|
||||
);
|
||||
const secureBackup = <SecureBackup />;
|
||||
|
||||
const eventIndex = (
|
||||
<SettingsSubsection heading={_t("settings|security|message_search_section")}>
|
||||
@@ -299,16 +301,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||
</SettingsSubsection>
|
||||
);
|
||||
|
||||
// XXX: There's no such panel in the current cross-signing designs, but
|
||||
// it's useful to have for testing the feature. If there's no interest
|
||||
// in having advanced details here once all flows are implemented, we
|
||||
// can remove this.
|
||||
const crossSigning = (
|
||||
<SettingsSubsection heading={_t("common|cross_signing")}>
|
||||
<CrossSigningPanel />
|
||||
</SettingsSubsection>
|
||||
);
|
||||
|
||||
let warning;
|
||||
if (!privateShouldBeEncrypted(MatrixClientPeg.safeGet())) {
|
||||
warning = (
|
||||
@@ -368,8 +360,6 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
|
||||
<SettingsSection heading={_t("settings|security|encryption_section")}>
|
||||
{secureBackup}
|
||||
{eventIndex}
|
||||
{crossSigning}
|
||||
<CryptographyPanel />
|
||||
</SettingsSection>
|
||||
<SettingsSection heading={_t("common|privacy")}>
|
||||
<DiscoverySettings />
|
||||
|
||||
@@ -1829,10 +1829,7 @@
|
||||
"toxic_behaviour": "Nevhodné chování"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Nahlaste tuto místnost správci domovského serveru. Tím se odešle jedinečné ID místnosti, ale pokud jsou zprávy zašifrovány, správce je nebude moci číst ani zobrazit sdílené soubory.",
|
||||
"reason_placeholder": " Důvod nahlášení...",
|
||||
"sent": "Vaše hlášení bylo odesláno.",
|
||||
"title": "Nahlášení místnosti"
|
||||
"description": "Nahlaste tuto místnost správci domovského serveru. Tím se odešle jedinečné ID místnosti, ale pokud jsou zprávy zašifrovány, správce je nebude moci číst ani zobrazit sdílené soubory."
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Nepovedlo se rozšifrovat %(failedCount)s sezení!",
|
||||
|
||||
@@ -814,6 +814,9 @@
|
||||
"setting_colon": "Gosodiad:",
|
||||
"setting_definition": "Gosod diffiniad:",
|
||||
"setting_id": "Gosod ID",
|
||||
"settings": {
|
||||
"elementCallUrl": "URL Galwad Element"
|
||||
},
|
||||
"settings_explorer": "Archwiliwr gosodiadau",
|
||||
"show_hidden_events": "Dangos digwyddiadau cudd yn y llinell amser",
|
||||
"state_key": "Allwedd Cyflwr",
|
||||
@@ -1606,6 +1609,7 @@
|
||||
"class_global": "Eang",
|
||||
"class_other": "Arall",
|
||||
"default": "Rhagosodedig",
|
||||
"default_settings": "Cydweddu'r gosodiadau rhagosodedig",
|
||||
"email_pusher_app_display_name": "Hysbysiadau E-bost",
|
||||
"enable_prompt_toast_description": "Galluogi hysbysiadau bwrdd gwaith",
|
||||
"enable_prompt_toast_title": "Hysbysiadau",
|
||||
@@ -1624,7 +1628,8 @@
|
||||
"mentions_and_keywords_description": "Dim ond gyda chyfeiriadau ac allweddeiriau fel y'u gosodwyd yn eich <a>gosodiadau</a> y cewch eich hysbysu",
|
||||
"mentions_keywords": "Crybwyll ac allweddeiriau",
|
||||
"message_didnt_send": "Heb anfon y neges. Cliciwch am wybodaeth.",
|
||||
"mute_description": "Fyddwch chi ddim yn cael unrhyw hysbysiadau"
|
||||
"mute_description": "Fyddwch chi ddim yn cael unrhyw hysbysiadau",
|
||||
"mute_room": "Tewi'r ystafell"
|
||||
},
|
||||
"notifier": {
|
||||
"m.key.verification.request": "Mae %(matere)s yn gofyn am ddilysiad"
|
||||
@@ -1746,10 +1751,7 @@
|
||||
"toxic_behaviour": "Ymddygiad Gwenwynig"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Adroddwch ar yr ystafell hon i weinyddwr eich gweinydd cartref. Bydd hyn yn anfon ID unigryw'r ystafell, ond os yw negeseuon wedi'u hamgryptio, fydd y gweinyddwr ddim yn gallu eu darllen na gweld ffeiliau sy'n cael eu rhannu.",
|
||||
"reason_placeholder": " Rheswm dros adrodd...",
|
||||
"sent": "Anfonwyd eich adroddiad.",
|
||||
"title": "Ystafell Adrodd"
|
||||
"description": "Adroddwch ar yr ystafell hon i weinyddwr eich gweinydd cartref. Bydd hyn yn anfon ID unigryw'r ystafell, ond os yw negeseuon wedi'u hamgryptio, fydd y gweinyddwr ddim yn gallu eu darllen na gweld ffeiliau sy'n cael eu rhannu."
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Wedi methu â dadgryptio %(failedCount)s sesiwn!",
|
||||
|
||||
@@ -1822,10 +1822,7 @@
|
||||
"toxic_behaviour": "Toxisches Verhalten"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Melden Sie diesen Raum Ihrem Homeserver-Administrator. Dadurch wird die eindeutige ID des Raums gesendet. Wenn Nachrichten verschlüsselt sind, kann der Administrator sie jedoch nicht lesen oder geteilte Dateien anzeigen.",
|
||||
"reason_placeholder": " Grund für die Meldung...",
|
||||
"sent": "Ihr Bericht wurde gesendet.",
|
||||
"title": "Raum melden"
|
||||
"description": "Melden Sie diesen Raum Ihrem Homeserver-Administrator. Dadurch wird die eindeutige ID des Raums gesendet. Wenn Nachrichten verschlüsselt sind, kann der Administrator sie jedoch nicht lesen oder geteilte Dateien anzeigen."
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Konnte %(failedCount)s Sitzungen nicht entschlüsseln!",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"other": "%(count)s unread messages including mentions."
|
||||
},
|
||||
"recent_rooms": "Recent rooms",
|
||||
"room_messsage_not_sent": "Open room %(roomName)s with an unset message.",
|
||||
"room_messsage_not_sent": "Open room %(roomName)s with an unsent message.",
|
||||
"room_n_unread_invite": "Open room %(roomName)s invitation.",
|
||||
"room_n_unread_messages": {
|
||||
"one": "Open room %(roomName)s with 1 unread message.",
|
||||
@@ -55,6 +55,8 @@
|
||||
"create_a_room": "Create a room",
|
||||
"create_account": "Create Account",
|
||||
"decline": "Decline",
|
||||
"decline_and_block": "Decline and block",
|
||||
"decline_invite": "Decline invite",
|
||||
"delete": "Delete",
|
||||
"deny": "Deny",
|
||||
"disable": "Disable",
|
||||
@@ -107,7 +109,6 @@
|
||||
"react": "React",
|
||||
"refresh": "Refresh",
|
||||
"register": "Register",
|
||||
"reject": "Reject",
|
||||
"reload": "Reload",
|
||||
"remove": "Remove",
|
||||
"rename": "Rename",
|
||||
@@ -486,7 +487,6 @@
|
||||
"capabilities": "Capabilities",
|
||||
"copied": "Copied!",
|
||||
"credits": "Credits",
|
||||
"cross_signing": "Cross-signing",
|
||||
"dark": "Dark",
|
||||
"description": "Description",
|
||||
"deselect_all": "Deselect all",
|
||||
@@ -747,6 +747,13 @@
|
||||
"twemoji": "The <twemoji>Twemoji</twemoji> emoji art is © <author>Twitter, Inc and other contributors</author> used under the terms of <terms>CC-BY 4.0</terms>.",
|
||||
"twemoji_colr": "The <colr>twemoji-colr</colr> font is © <author>Mozilla Foundation</author> used under the terms of <terms>Apache 2.0</terms>."
|
||||
},
|
||||
"decline_invitation_dialog": {
|
||||
"confirm": "Are you sure you want to decline the invitation to join \"%(roomName)s\"?",
|
||||
"ignore_user_help": "You will not see any messages or room invites from this user.",
|
||||
"reason_description": "Describe the reason for reporting the room.",
|
||||
"report_room_description": "Report this room to your account provider.",
|
||||
"title": "Decline invitation"
|
||||
},
|
||||
"desktop_default_device_name": "%(brand)s Desktop: %(platformName)s",
|
||||
"devtools": {
|
||||
"active_widgets": "Active Widgets",
|
||||
@@ -924,22 +931,12 @@
|
||||
"cancel_entering_passphrase_title": "Cancel entering passphrase?",
|
||||
"confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.",
|
||||
"confirm_encryption_setup_title": "Confirm encryption setup",
|
||||
"cross_signing_not_ready": "Cross-signing is not set up.",
|
||||
"cross_signing_ready": "Cross-signing is ready for use.",
|
||||
"cross_signing_ready_no_backup": "Cross-signing is ready but keys are not backed up.",
|
||||
"cross_signing_room_normal": "This room is end-to-end encrypted",
|
||||
"cross_signing_room_verified": "Everyone in this room is verified",
|
||||
"cross_signing_room_warning": "Someone is using an unknown session",
|
||||
"cross_signing_unsupported": "Your homeserver does not support cross-signing.",
|
||||
"cross_signing_untrusted": "Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
|
||||
"cross_signing_user_normal": "You have not verified this user.",
|
||||
"cross_signing_user_verified": "You have verified this user. This user has verified all of their sessions.",
|
||||
"cross_signing_user_warning": "This user has not verified all of their sessions.",
|
||||
"destroy_cross_signing_dialog": {
|
||||
"primary_button_text": "Clear cross-signing keys",
|
||||
"title": "Destroy cross-signing keys?",
|
||||
"warning": "Deleting cross-signing keys is permanent. Anyone you have verified with will see security alerts. You almost certainly don't want to do this, unless you've lost every device you can cross-sign from."
|
||||
},
|
||||
"enter_recovery_key": "Enter recovery key",
|
||||
"event_shield_reason_authenticity_not_guaranteed": "The authenticity of this encrypted message can't be guaranteed on this device.",
|
||||
"event_shield_reason_mismatched_sender_key": "Encrypted by an unverified session",
|
||||
@@ -966,7 +963,6 @@
|
||||
"title": "New Recovery Method",
|
||||
"warning": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings."
|
||||
},
|
||||
"not_supported": "<not supported>",
|
||||
"pinned_identity_changed": "%(displayName)s's (<b>%(userId)s</b>) identity was reset. <a>Learn more</a>",
|
||||
"pinned_identity_changed_no_displayname": "<b>%(userId)s</b>'s identity was reset. <a>Learn more</a>",
|
||||
"recovery_method_removed": {
|
||||
@@ -982,8 +978,7 @@
|
||||
"set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
|
||||
"set_up_toast_title": "Set up Secure Backup",
|
||||
"setup_secure_backup": {
|
||||
"explainer": "Back up your keys before signing out to avoid losing them.",
|
||||
"title": "Set up"
|
||||
"explainer": "Back up your keys before signing out to avoid losing them."
|
||||
},
|
||||
"udd": {
|
||||
"interactive_verification_button": "Interactively verify by emoji",
|
||||
@@ -1800,11 +1795,6 @@
|
||||
"ongoing": "Removing…",
|
||||
"reason_label": "Reason (optional)"
|
||||
},
|
||||
"reject_invitation_dialog": {
|
||||
"confirmation": "Are you sure you want to reject the invitation?",
|
||||
"failed": "Failed to reject invitation",
|
||||
"title": "Reject invitation"
|
||||
},
|
||||
"report_content": {
|
||||
"description": "Reporting this message will send its unique 'event ID' to the administrator of your homeserver. If messages in this room are encrypted, your homeserver administrator will not be able to read the message text or view any files or images.",
|
||||
"disagree": "Disagree",
|
||||
@@ -2014,7 +2004,6 @@
|
||||
"you_created": "You created this room."
|
||||
},
|
||||
"invite_email_mismatch_suggestion": "Share this email in Settings to receive invites directly in %(brand)s.",
|
||||
"invite_reject_ignore": "Reject & Ignore user",
|
||||
"invite_sent_to_email": "This invite was sent to %(email)s",
|
||||
"invite_sent_to_email_room": "This invite to %(roomName)s was sent to %(email)s",
|
||||
"invite_subtitle": "Invited by <userName/>",
|
||||
@@ -2837,57 +2826,20 @@
|
||||
"prompt_invite": "Prompt before sending invites to potentially invalid matrix IDs",
|
||||
"replace_plain_emoji": "Automatically replace plain text Emoji",
|
||||
"security": {
|
||||
"4s_public_key_in_account_data": "in account data",
|
||||
"4s_public_key_status": "Secret storage public key:",
|
||||
"analytics_description": "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
|
||||
"backup_key_cached_status": "Backup key cached:",
|
||||
"backup_key_stored_status": "Backup key stored:",
|
||||
"backup_key_unexpected_type": "unexpected type",
|
||||
"backup_key_well_formed": "well formed",
|
||||
"backup_keys_description": "Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Recovery Key.",
|
||||
"bulk_options_accept_all_invites": "Accept all %(invitedRooms)s invites",
|
||||
"bulk_options_reject_all_invites": "Reject all %(invitedRooms)s invites",
|
||||
"bulk_options_section": "Bulk options",
|
||||
"cross_signing_cached": "cached locally",
|
||||
"cross_signing_homeserver_support": "Homeserver feature support:",
|
||||
"cross_signing_homeserver_support_exists": "exists",
|
||||
"cross_signing_in_4s": "in secret storage",
|
||||
"cross_signing_in_memory": "in memory",
|
||||
"cross_signing_master_private_Key": "Master private key:",
|
||||
"cross_signing_not_cached": "not found locally",
|
||||
"cross_signing_not_found": "not found",
|
||||
"cross_signing_not_in_4s": "not found in storage",
|
||||
"cross_signing_not_stored": "not stored",
|
||||
"cross_signing_private_keys": "Cross-signing private keys:",
|
||||
"cross_signing_public_keys": "Cross-signing public keys:",
|
||||
"cross_signing_self_signing_private_key": "Self signing private key:",
|
||||
"cross_signing_user_signing_private_key": "User signing private key:",
|
||||
"cryptography_section": "Cryptography",
|
||||
"dehydrated_device_description": "The offline device feature allows you to receive encrypted messages even when you are not logged in to any devices",
|
||||
"dehydrated_device_enabled": "Offline device enabled",
|
||||
"delete_backup": "Delete Backup",
|
||||
"delete_backup_confirm_description": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.",
|
||||
"dialog_title": "<strong>Settings:</strong> Security & Privacy",
|
||||
"e2ee_default_disabled_warning": "Your server admin has disabled end-to-end encryption by default in private rooms & Direct Messages.",
|
||||
"enable_message_search": "Enable message search in encrypted rooms",
|
||||
"encryption_section": "Encryption",
|
||||
"error_loading_key_backup_status": "Unable to load key backup status",
|
||||
"export_megolm_keys": "Export E2E room keys",
|
||||
"ignore_users_empty": "You have no ignored users.",
|
||||
"ignore_users_section": "Ignored users",
|
||||
"import_megolm_keys": "Import E2E room keys",
|
||||
"key_backup_active": "This session is backing up your keys.",
|
||||
"key_backup_active_version": "Active backup version:",
|
||||
"key_backup_active_version_none": "None",
|
||||
"key_backup_algorithm": "Algorithm:",
|
||||
"key_backup_can_be_restored": "This backup can be restored on this session",
|
||||
"key_backup_complete": "All keys backed up",
|
||||
"key_backup_connect": "Connect this session to Key Backup",
|
||||
"key_backup_connect_prompt": "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.",
|
||||
"key_backup_in_progress": "Backing up %(sessionsRemaining)s keys…",
|
||||
"key_backup_inactive": "This session is <b>not backing up your keys</b>, but you do have an existing backup you can restore from and add to going forward.",
|
||||
"key_backup_inactive_warning": "Your keys are <b>not being backed up from this session</b>.",
|
||||
"key_backup_latest_version": "Latest backup version on server:",
|
||||
"message_search_disable_warning": "If disabled, messages from encrypted rooms won't appear in search results.",
|
||||
"message_search_disabled": "Securely cache encrypted messages locally for them to appear in search results.",
|
||||
"message_search_enabled": {
|
||||
@@ -2907,13 +2859,7 @@
|
||||
"message_search_unsupported": "%(brand)s is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom %(brand)s Desktop with <nativeLink>search components added</nativeLink>.",
|
||||
"message_search_unsupported_web": "%(brand)s can't securely cache encrypted messages locally while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> for encrypted messages to appear in search results.",
|
||||
"record_session_details": "Record the client name, version, and url to recognise sessions more easily in session manager",
|
||||
"restore_key_backup": "Restore from Backup",
|
||||
"secret_storage_not_ready": "not ready",
|
||||
"secret_storage_ready": "ready",
|
||||
"secret_storage_status": "Secret storage:",
|
||||
"send_analytics": "Send analytics data",
|
||||
"session_id": "Session ID:",
|
||||
"session_key": "Session key:",
|
||||
"strict_encryption": "Only send messages to verified users"
|
||||
},
|
||||
"send_read_receipts": "Send read receipts",
|
||||
|
||||
@@ -846,6 +846,9 @@
|
||||
"setting_colon": "Paramètre :",
|
||||
"setting_definition": "Définition du paramètre :",
|
||||
"setting_id": "Identifiant de paramètre",
|
||||
"settings": {
|
||||
"elementCallUrl": "Lien d'Element Call"
|
||||
},
|
||||
"settings_explorer": "Explorateur de paramètres",
|
||||
"show_hidden_events": "Afficher les évènements cachés dans le fil de discussion",
|
||||
"spaces": {
|
||||
@@ -1674,6 +1677,7 @@
|
||||
"class_global": "Global",
|
||||
"class_other": "Autre",
|
||||
"default": "Par défaut",
|
||||
"default_settings": "Correspondre aux paramètres par défaut",
|
||||
"email_pusher_app_display_name": "Notifications par courriel",
|
||||
"enable_prompt_toast_description": "Activer les notifications sur le bureau",
|
||||
"enable_prompt_toast_title": "Notifications",
|
||||
@@ -1692,7 +1696,8 @@
|
||||
"mentions_and_keywords_description": "Recevoir des notifications uniquement pour les mentions et mot-clés comme défini dans vos <a>paramètres</a>",
|
||||
"mentions_keywords": "Mentions et mots-clés",
|
||||
"message_didnt_send": "Le message n’a pas été envoyé. Cliquer pour plus d’info.",
|
||||
"mute_description": "Vous n’aurez aucune notification"
|
||||
"mute_description": "Vous n’aurez aucune notification",
|
||||
"mute_room": "Rendre le salon muet"
|
||||
},
|
||||
"notifier": {
|
||||
"m.key.verification.request": "%(name)s demande une vérification"
|
||||
@@ -1822,10 +1827,8 @@
|
||||
"toxic_behaviour": "Comportement toxique"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Signalez cette salle à l'administrateur de votre serveur d'accueil. Cela enverra l'identifiant unique du salon, mais si les messages sont chiffrés, l'administrateur ne pourra pas les lire ni consulter les fichiers partagés.",
|
||||
"reason_placeholder": " Motif du signalement...",
|
||||
"sent": "Votre signalement a été envoyé.",
|
||||
"title": "Signaler le salon"
|
||||
"description": "Signalez ce salon à votre fournisseur de compte. Si les messages sont chiffrés, votre administrateur ne pourra pas les lire.",
|
||||
"reason_label": "Décrivez la raison"
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Le déchiffrement de %(failedCount)s sessions a échoué !",
|
||||
|
||||
@@ -1814,10 +1814,7 @@
|
||||
"toxic_behaviour": "Mérgező viselkedés"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "A szoba jelentése a Matrix-kiszolgáló rendszergazdájának. Ez elküldi a szoba egyedi azonosítóját, de ha az üzenetek titkosítva vannak, a rendszergazda nem fogja tudni elolvasni azokat, vagy megtekinteni a megosztott fájlokat.",
|
||||
"reason_placeholder": " A jelentés oka…",
|
||||
"sent": "A jelentés elküldve.",
|
||||
"title": "Szoba jelentése"
|
||||
"description": "A szoba jelentése a Matrix-kiszolgáló rendszergazdájának. Ez elküldi a szoba egyedi azonosítóját, de ha az üzenetek titkosítva vannak, a rendszergazda nem fogja tudni elolvasni azokat, vagy megtekinteni a megosztott fájlokat."
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "%(failedCount)s munkamenetet nem lehet visszafejteni!",
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
"other": "%(count)s uleste meldinger, inkludert omtaler."
|
||||
},
|
||||
"recent_rooms": "Nylige rom",
|
||||
"room_messsage_not_sent": "Åpent rom %(roomName)s med en usendt melding.",
|
||||
"room_n_unread_invite": "Invitasjon til det åpne rommet %(roomName)s.",
|
||||
"room_n_unread_messages": {
|
||||
"one": "Åpne room %(roomName)s med 1 ulest message.",
|
||||
"other": "Åpne room %(roomName)s med %(count)s uleste messages."
|
||||
},
|
||||
"room_n_unread_messages_mentions": {
|
||||
"one": "Åpne room %(roomName)s med 1 ulest omtale.",
|
||||
"other": "Åpne room %(roomName)s med %(count)s uleste meldinger inkludert omtaler."
|
||||
},
|
||||
"room_name": "Rom %(name)s",
|
||||
"room_status_bar": "Statuslinje for rommet",
|
||||
"seek_bar_label": "Søkelinje for lyd",
|
||||
@@ -1668,6 +1677,7 @@
|
||||
"class_global": "Globalt",
|
||||
"class_other": "Andre",
|
||||
"default": "Standard",
|
||||
"default_settings": "Match standardinnstillingene",
|
||||
"email_pusher_app_display_name": "E-postvarsler",
|
||||
"enable_prompt_toast_description": "Aktiver skrivebordsvarsler",
|
||||
"enable_prompt_toast_title": "Varsler",
|
||||
@@ -1686,7 +1696,8 @@
|
||||
"mentions_and_keywords_description": "Bli varslet bare med omtaler og nøkkelord som konfigurert i <a> innstillingene dine </a>",
|
||||
"mentions_keywords": "Omtaler og nøkkelord",
|
||||
"message_didnt_send": "Meldingen ble ikke sendt. Klikk for informasjon.",
|
||||
"mute_description": "Du vil ikke få noen varsler"
|
||||
"mute_description": "Du vil ikke få noen varsler",
|
||||
"mute_room": "Demp rommet"
|
||||
},
|
||||
"notifier": {
|
||||
"m.key.verification.request": "%(name)s ber om verifisering"
|
||||
@@ -1817,9 +1828,7 @@
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Rapporter dette rommet til hjemmeserveradministratoren din. Dette vil sende rommets unike ID, men hvis meldinger er kryptert, vil ikke administratoren kunne lese dem eller se delte filer.",
|
||||
"reason_placeholder": " Årsak til rapportering...",
|
||||
"sent": "Rapporten din ble sendt.",
|
||||
"title": "Rapporter rom"
|
||||
"reason_label": "Beskriv årsaken"
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Kunne ikke dekryptere %(failedCount)s økter!",
|
||||
@@ -3782,12 +3791,12 @@
|
||||
"other": "%(severalUsers)sendret serverens ACLer %(count)s ganger"
|
||||
},
|
||||
"unbanned": {
|
||||
"one": "fikk opphevet forbudet",
|
||||
"other": "fikk opphevet forbudet %(count)s ganger"
|
||||
"one": "fikk utestengelse opphevet",
|
||||
"other": "har fått utestengelse opphevet %(count)s ganger"
|
||||
},
|
||||
"unbanned_multiple": {
|
||||
"one": "fikk opphevet forbudet",
|
||||
"other": "fikk opphevet forbudet %(count)s ganger"
|
||||
"one": "har fått utestengelse opphevet",
|
||||
"other": "fått utestengelse opphevet %(count)s ganger"
|
||||
}
|
||||
},
|
||||
"thread_info_basic": "Fra en tråd",
|
||||
|
||||
@@ -1690,6 +1690,7 @@
|
||||
"class_global": "Globalne",
|
||||
"class_other": "Inne",
|
||||
"default": "Domyślne",
|
||||
"default_settings": "Ustawienia domyślne",
|
||||
"email_pusher_app_display_name": "Powiadomienia e-mail",
|
||||
"enable_prompt_toast_description": "Włącz powiadomienia na pulpicie",
|
||||
"enable_prompt_toast_title": "Powiadomienia",
|
||||
@@ -1708,7 +1709,8 @@
|
||||
"mentions_and_keywords_description": "Otrzymuj powiadomienia tylko z wzmiankami i słowami kluczowymi zgodnie z Twoimi <a>ustawieniami</a>",
|
||||
"mentions_keywords": "Wzmianki i słowa kluczowe",
|
||||
"message_didnt_send": "Nie wysłano wiadomości. Kliknij po więcej informacji.",
|
||||
"mute_description": "Nie otrzymasz żadnych powiadomień"
|
||||
"mute_description": "Nie otrzymasz żadnych powiadomień",
|
||||
"mute_room": "Wycisz pokój"
|
||||
},
|
||||
"notifier": {
|
||||
"m.key.verification.request": "%(name)s prosi o weryfikację"
|
||||
@@ -1838,10 +1840,8 @@
|
||||
"toxic_behaviour": "Toksyczne zachowanie"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Zgłoś ten pokój do administratora serwera domowego. Unikalne ID pokoju zostanie wysłane, lecz administrator nie będzie w mógł przeczytać żadnych wiadomości, ani plików szyfrowanych.",
|
||||
"reason_placeholder": " Powód zgłoszenia...",
|
||||
"sent": "Twój raport został wysłany.",
|
||||
"title": "Zgłoś pokój"
|
||||
"description": "Zgłoś ten pokój swojemu dostawcy konta. Jeśli wiadomości są zaszyfrowane, administrator nie będzie mógł ich odczytać.",
|
||||
"reason_label": "Opisz powód"
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Nie udało się odszyfrować %(failedCount)s sesji!",
|
||||
|
||||
@@ -1681,6 +1681,7 @@
|
||||
"class_global": "Глобально",
|
||||
"class_other": "Інше",
|
||||
"default": "Типовий",
|
||||
"default_settings": "Згідно з усталеними налаштуваннями",
|
||||
"email_pusher_app_display_name": "Сповіщення е-поштою",
|
||||
"enable_prompt_toast_description": "Увімкнути сповіщення стільниці",
|
||||
"enable_prompt_toast_title": "Сповіщення",
|
||||
@@ -1699,7 +1700,8 @@
|
||||
"mentions_and_keywords_description": "Отримувати лише вказані у ваших <a>налаштуваннях</a> згадки й ключові слова",
|
||||
"mentions_keywords": "Згадки та ключові слова",
|
||||
"message_didnt_send": "Повідомлення не надіслане. Натисніть, щоб дізнатись більше.",
|
||||
"mute_description": "Ви не отримуватимете жодних сповіщень"
|
||||
"mute_description": "Ви не отримуватимете жодних сповіщень",
|
||||
"mute_room": "Вимкнути сповіщення кімнати"
|
||||
},
|
||||
"notifier": {
|
||||
"m.key.verification.request": "%(name)s робить запит на звірення"
|
||||
@@ -1829,10 +1831,8 @@
|
||||
"toxic_behaviour": "Токсична поведінка"
|
||||
},
|
||||
"report_room": {
|
||||
"description": "Поскаржитися на цю кімнату адміністратору домашнього сервера. Це надішле унікальний ID кімнати, але якщо повідомлення зашифровані, адміністратор не зможе прочитати їх або переглянути спільні файли.",
|
||||
"reason_placeholder": "Причина для скарги...",
|
||||
"sent": "Вашу скаргу надіслано.",
|
||||
"title": "Поскаржитися на кімнату"
|
||||
"description": "Поскаржитися на цю кімнату постачальнику облікового запису. Якщо повідомлення зашифровані, адміністратор не зможе прочитати їх.",
|
||||
"reason_label": "Опишіть причину"
|
||||
},
|
||||
"restore_key_backup_dialog": {
|
||||
"count_of_decryption_failures": "Не вдалося розшифрувати %(failedCount)s сеансів!",
|
||||
|
||||
@@ -56,17 +56,6 @@ export function formatBytes(bytes: number, decimals = 2): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* format a key into groups of 4 characters, for easier visual inspection
|
||||
*
|
||||
* @param {string} key key to format
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function formatCryptoKey(key: string): string {
|
||||
return key.match(/.{1,4}/g)!.join(" ");
|
||||
}
|
||||
|
||||
export function getUserNameColorClass(userId: string): string {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const number = useIdColorHash(userId);
|
||||
|
||||
Reference in New Issue
Block a user