Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -0,0 +1,123 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { FormEvent, useCallback, useContext, useRef, useState } from "react";
import { Room, EventType } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { ICompletion } from "../../../autocomplete/Autocompleter";
import UserProvider from "../../../autocomplete/UserProvider";
import { AutocompleteInput } from "../../structures/AutocompleteInput";
import PowerSelector from "../elements/PowerSelector";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import SettingsFieldset from "./SettingsFieldset";
interface AddPrivilegedUsersProps {
room: Room;
defaultUserLevel: number;
}
export const AddPrivilegedUsers: React.FC<AddPrivilegedUsersProps> = ({ room, defaultUserLevel }) => {
const client = useContext(MatrixClientContext);
const userProvider = useRef(new UserProvider(room));
const [isLoading, setIsLoading] = useState<boolean>(false);
const [powerLevel, setPowerLevel] = useState<number>(defaultUserLevel);
const [selectedUsers, setSelectedUsers] = useState<ICompletion[]>([]);
const hasLowerOrEqualLevelThanDefaultLevelFilter = useCallback(
(user: ICompletion) => hasLowerOrEqualLevelThanDefaultLevel(room, user, defaultUserLevel),
[room, defaultUserLevel],
);
const onSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
setIsLoading(true);
const userIds = getUserIdsFromCompletions(selectedUsers);
const powerLevelEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
// `RoomPowerLevels` event should exist, but technically it is not guaranteed.
if (powerLevelEvent === null) {
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("error|update_power_level"),
});
return;
}
try {
await client.setPowerLevel(room.roomId, userIds, powerLevel);
setSelectedUsers([]);
setPowerLevel(defaultUserLevel);
} catch (error) {
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("error|update_power_level"),
});
} finally {
setIsLoading(false);
}
};
return (
<form style={{ display: "flex" }} onSubmit={onSubmit}>
<SettingsFieldset
legend={_t("room_settings|permissions|add_privileged_user_heading")}
description={_t("room_settings|permissions|add_privileged_user_description")}
style={{ flexGrow: 1 }}
>
<AutocompleteInput
provider={userProvider.current}
placeholder={_t("room_settings|permissions|add_privileged_user_filter_placeholder")}
onSelectionChange={setSelectedUsers}
selection={selectedUsers}
additionalFilter={hasLowerOrEqualLevelThanDefaultLevelFilter}
/>
<PowerSelector value={powerLevel} onChange={setPowerLevel} />
<AccessibleButton
type="submit"
element="button"
kind="primary"
disabled={!selectedUsers.length || isLoading}
onClick={null}
data-testid="add-privileged-users-submit-button"
>
{_t("action|apply")}
</AccessibleButton>
</SettingsFieldset>
</form>
);
};
export const hasLowerOrEqualLevelThanDefaultLevel = (
room: Room,
user: ICompletion,
defaultUserLevel: number,
): boolean => {
if (user.completionId === undefined) {
return false;
}
const member = room.getMember(user.completionId);
if (member === null) {
return false;
}
return member.powerLevel <= defaultUserLevel;
};
export const getUserIdsFromCompletions = (completions: ICompletion[]): string[] => {
const completionsWithId = completions.filter((completion) => completion.completionId !== undefined);
// undefined completionId's are filtered out above but TypeScript does not seem to understand.
return completionsWithId.map((completion) => completion.completionId!);
};

View file

@ -0,0 +1,526 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import {
IRequestMsisdnTokenResponse,
IRequestTokenResponse,
MatrixError,
ThreepidMedium,
} from "matrix-js-sdk/src/matrix";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../AddThreepid";
import { _t, UserFriendlyError } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import Modal from "../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../dialogs/ErrorDialog";
import Field from "../elements/Field";
import { looksValid as emailLooksValid } from "../../../email";
import CountryDropdown from "../auth/CountryDropdown";
import { PhoneNumberCountryDefinition } from "../../../phonenumber";
import InlineSpinner from "../elements/InlineSpinner";
// Whether we're adding 3pids to the user's account on the homeserver or sharing them on an identity server
type TheepidControlMode = "hs" | "is";
interface ExistingThreepidProps {
mode: TheepidControlMode;
threepid: ThirdPartyIdentifier;
onChange: (threepid: ThirdPartyIdentifier) => void;
disabled?: boolean;
}
const ExistingThreepid: React.FC<ExistingThreepidProps> = ({ mode, threepid, onChange, disabled }) => {
const [isConfirming, setIsConfirming] = useState(false);
const client = useMatrixClientContext();
const bindTask = useRef<AddThreepid | undefined>();
const [isVerifyingBind, setIsVerifyingBind] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const onRemoveClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(true);
}, []);
const onCancelClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(false);
}, []);
const onConfirmRemoveClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
client
.deleteThreePid(threepid.medium, threepid.address)
.then(() => {
return onChange(threepid);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
},
[client, threepid, onChange],
);
const changeBinding = useCallback(
async ({ bind, label, errorTitle }: Binding) => {
try {
if (bind) {
bindTask.current = new AddThreepid(client);
setContinueDisabled(true);
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current.bindEmailAddress(threepid.address);
} else {
// XXX: Sydent will accept a number without country code if you add
// a leading plus sign to a number in E.164 format (which the 3PID
// address is), but this goes against the spec.
// See https://github.com/matrix-org/matrix-doc/issues/2222
await bindTask.current.bindMsisdn(null as unknown as string, `+${threepid.address}`);
}
setContinueDisabled(false);
setIsVerifyingBind(true);
} else {
await client.unbindThreePid(threepid.medium, threepid.address);
onChange(threepid);
}
} catch (err) {
logger.error(`changeBinding: Unable to ${label} email address ${threepid.address}`, err);
setIsVerifyingBind(false);
setContinueDisabled(false);
bindTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
},
[client, threepid, onChange],
);
const onRevokeClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: false,
label: "revoke",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_revoke_email_discovery")
: _t("settings|general|error_revoke_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onShareClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: true,
label: "share",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_share_email_discovery")
: _t("settings|general|error_share_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onContinueClick = useCallback(
async (e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setContinueDisabled(true);
try {
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current?.checkEmailLinkClicked();
} else {
await bindTask.current?.haveMsisdnToken(verificationCode);
}
setIsVerifyingBind(false);
onChange(threepid);
bindTask.current = undefined;
} catch (err) {
logger.error(`Unable to verify threepid:`, err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title:
threepid.medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description:
threepid.medium === "email"
? _t("settings|general|email_verification_instructions")
: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
} finally {
setContinueDisabled(false);
}
},
[verificationCode, onChange, threepid],
);
const onVerificationCodeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCode(e.target.value);
}, []);
if (isConfirming) {
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_promptText">
{threepid.medium === ThreepidMedium.Email
? _t("settings|general|remove_email_prompt", { email: threepid.address })
: _t("settings|general|remove_msisdn_prompt", { phone: threepid.address })}
</span>
<AccessibleButton
onClick={onConfirmRemoveClick}
kind="danger_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={onCancelClick}
kind="link_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
if (isVerifyingBind) {
if (threepid.medium === ThreepidMedium.Email) {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|discovery_email_verification_instructions")}
</span>
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_existing_button"
kind="primary_sm"
onClick={onContinueClick}
disabled={continueDisabled}
>
{_t("action|complete")}
</AccessibleButton>
</div>
);
} else {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|msisdn_verification_instructions")}
</span>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={continueDisabled}
value={verificationCode}
onChange={onVerificationCodeChange}
/>
</form>
</div>
);
}
}
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_address">{threepid.address}</span>
<AccessibleButton
onClick={mode === "hs" ? onRemoveClick : threepid.bound ? onRevokeClick : onShareClick}
kind={mode === "hs" || threepid.bound ? "danger_sm" : "primary_sm"}
disabled={disabled}
>
{mode === "hs" ? _t("action|remove") : threepid.bound ? _t("action|revoke") : _t("action|share")}
</AccessibleButton>
</div>
);
};
function isMsisdnResponse(
resp: IRequestTokenResponse | IRequestMsisdnTokenResponse,
): resp is IRequestMsisdnTokenResponse {
return (resp as IRequestMsisdnTokenResponse).msisdn !== undefined;
}
const AddThreepidSection: React.FC<{ medium: "email" | "msisdn"; disabled?: boolean; onChange: () => void }> = ({
medium,
disabled,
onChange,
}) => {
const addTask = useRef<AddThreepid | undefined>();
const [newThreepidInput, setNewThreepidInput] = useState("");
const [phoneCountryInput, setPhoneCountryInput] = useState("");
const [verificationCodeInput, setVerificationCodeInput] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [sentToMsisdn, setSentToMsisdn] = useState("");
const client = useMatrixClientContext();
const onPhoneCountryChanged = useCallback((country: PhoneNumberCountryDefinition) => {
setPhoneCountryInput(country.iso2);
}, []);
const onContinueClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
if (!addTask.current) return;
setContinueDisabled(true);
const checkPromise =
medium === "email"
? addTask.current?.checkEmailLinkClicked()
: addTask.current?.haveMsisdnToken(verificationCodeInput);
checkPromise
.then(([finished]) => {
if (finished) {
addTask.current = undefined;
setIsVerifying(false);
setNewThreepidInput("");
onChange();
}
setContinueDisabled(false);
})
.catch((err) => {
logger.error("Unable to verify 3pid: ", err);
setContinueDisabled(false);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (
underlyingError instanceof MatrixError &&
underlyingError.errcode === "M_THREEPID_AUTH_FAILED"
) {
Modal.createDialog(ErrorDialog, {
title:
medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
Modal.createDialog(ErrorDialog, {
title:
medium == "email"
? _t("settings|general|error_email_verification")
: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
});
},
[onChange, medium, verificationCodeInput],
);
const onNewThreepidInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewThreepidInput(e.target.value);
}, []);
const onAddClick = useCallback(
(e: React.FormEvent) => {
e.stopPropagation();
e.preventDefault();
if (!newThreepidInput) return;
// TODO: Inline field validation
if (medium === "email" && !emailLooksValid(newThreepidInput)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
addTask.current = new AddThreepid(client);
setIsVerifying(true);
setContinueDisabled(true);
const addPromise =
medium === "email"
? addTask.current.addEmailAddress(newThreepidInput)
: addTask.current.addMsisdn(phoneCountryInput, newThreepidInput);
addPromise
.then((resp: IRequestTokenResponse | IRequestMsisdnTokenResponse) => {
setContinueDisabled(false);
if (isMsisdnResponse(resp)) {
setSentToMsisdn(resp.msisdn);
}
})
.catch((err) => {
logger.error(`Unable to add threepid ${newThreepidInput}`, err);
setIsVerifying(false);
setContinueDisabled(false);
addTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: medium === "email" ? _t("settings|general|error_add_email") : _t("common|error"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
},
[client, phoneCountryInput, newThreepidInput, medium],
);
const onVerificationCodeInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCodeInput(e.target.value);
}, []);
if (isVerifying && medium === "email") {
return (
<div>
<div>{_t("settings|general|add_email_instructions")}</div>
<AccessibleButton onClick={onContinueClick} kind="primary" disabled={continueDisabled}>
{_t("action|continue")}
</AccessibleButton>
</div>
);
} else if (isVerifying) {
return (
<div>
<div>
{_t("settings|general|add_msisdn_instructions", { msisdn: sentToMsisdn })}
<br />
</div>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={disabled || continueDisabled}
value={verificationCodeInput}
onChange={onVerificationCodeInputChange}
/>
<AccessibleButton
onClick={onContinueClick}
kind="primary"
disabled={disabled || continueDisabled || verificationCodeInput.length === 0}
>
{_t("action|continue")}
</AccessibleButton>
</form>
</div>
);
}
const phoneCountry =
medium === "msisdn" ? (
<CountryDropdown
onOptionChange={onPhoneCountryChanged}
className="mx_PhoneNumbers_country"
value={phoneCountryInput}
disabled={isVerifying}
isSmall={true}
showPrefix={true}
/>
) : undefined;
return (
<form onSubmit={onAddClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={
medium === "email"
? _t("settings|general|email_address_label")
: _t("settings|general|msisdn_label")
}
autoComplete={medium === "email" ? "email" : "tel-national"}
disabled={disabled || isVerifying}
value={newThreepidInput}
onChange={onNewThreepidInputChange}
prefixComponent={phoneCountry}
/>
<AccessibleButton onClick={onAddClick} kind="primary" disabled={disabled}>
{_t("action|add")}
</AccessibleButton>
</form>
);
};
interface AddRemoveThreepidsProps {
// Whether the control is for adding 3pids to the user's homeserver account or sharing them on an IS
mode: TheepidControlMode;
// Whether the control is for emails or phone numbers
medium: ThreepidMedium;
// The current list of third party identifiers
threepids: ThirdPartyIdentifier[];
// If true, the component is disabled and no third party identifiers can be added or removed
disabled?: boolean;
// Called when changes are made to the list of third party identifiers
onChange: () => void;
// If true, a spinner is shown instead of the component
isLoading: boolean;
}
export const AddRemoveThreepids: React.FC<AddRemoveThreepidsProps> = ({
mode,
medium,
threepids,
disabled,
onChange,
isLoading,
}) => {
if (isLoading) {
return <InlineSpinner />;
}
const existingEmailElements = threepids.map((e) => {
return <ExistingThreepid mode={mode} threepid={e} onChange={onChange} key={e.address} disabled={disabled} />;
});
return (
<>
{existingEmailElements}
{mode === "hs" && <AddThreepidSection medium={medium} disabled={disabled} onChange={onChange} />}
</>
);
};

View file

@ -0,0 +1,230 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2024 The Matrix.org Foundation C.I.C.
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, createRef, useCallback, useEffect, useState } from "react";
import EditIcon from "@vector-im/compound-design-tokens/assets/web/icons/edit";
import UploadIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import { Menu, MenuItem } from "@vector-im/compound-web";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { mediaFromMxc } from "../../../customisations/Media";
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
import { useId } from "../../../utils/useId";
import AccessibleButton from "../elements/AccessibleButton";
import BaseAvatar from "../avatars/BaseAvatar";
interface MenuProps {
trigger: ReactNode;
onUploadSelect: () => void;
onRemoveSelect?: () => void;
menuOpen: boolean;
onOpenChange: (newOpen: boolean) => void;
}
const AvatarSettingContextMenu: React.FC<MenuProps> = ({
trigger,
onUploadSelect,
onRemoveSelect,
menuOpen,
onOpenChange,
}) => {
return (
<Menu
trigger={trigger}
title={_t("action|set_avatar")}
showTitle={false}
open={menuOpen}
onOpenChange={onOpenChange}
>
<MenuItem
as="div"
Icon={<UploadIcon width="24px" height="24px" />}
label={_t("action|upload_file")}
onSelect={onUploadSelect}
/>
{onRemoveSelect && (
<MenuItem
as="div"
Icon={<DeleteIcon width="24px" height="24px" />}
className="mx_AvatarSetting_removeMenuItem"
label={_t("action|remove")}
onSelect={onRemoveSelect}
/>
)}
</Menu>
);
};
interface IProps {
/**
* The current value of the avatar URL, as an mxc URL or a File.
* Generally, an mxc URL would be specified until the user selects a file, then
* the file supplied by the onChange callback would be supplied here until it's
* saved.
*/
avatar?: string | File;
/**
* If true, the user cannot change the avatar
*/
disabled?: boolean;
/**
* Called when the user has selected a new avatar
* The callback is passed a File object for the new avatar data
*/
onChange?: (f: File) => void;
/**
* Called when the user wishes to remove the avatar
*/
removeAvatar?: () => void;
/**
* The alt text for the avatar
*/
avatarAltText: string;
/**
* String to use for computing the colour of the placeholder avatar if no avatar is set
*/
placeholderId: string;
/**
* String to use for the placeholder display if no avatar is set
*/
placeholderName: string;
}
/**
* Component for setting or removing an avatar on something (eg. a user or a room)
*/
const AvatarSetting: React.FC<IProps> = ({
avatar,
avatarAltText,
onChange,
removeAvatar,
disabled,
placeholderId,
placeholderName,
}) => {
const fileInputRef = createRef<HTMLInputElement>();
// Real URL that we can supply to the img element, either a data URL or whatever mediaFromMxc gives
// This represents whatever avatar the user has chosen at the time
const [avatarURL, setAvatarURL] = useState<string | undefined>(undefined);
useEffect(() => {
if (avatar instanceof File) {
const reader = new FileReader();
reader.onload = () => {
setAvatarURL(reader.result as string);
};
reader.readAsDataURL(avatar);
} else if (avatar) {
setAvatarURL(mediaFromMxc(avatar).getSquareThumbnailHttp(96) ?? undefined);
} else {
setAvatarURL(undefined);
}
}, [avatar]);
// Prevents ID collisions when this component is used more than once on the same page.
const a11yId = useId();
const onFileChanged = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) onChange?.(e.target.files[0]);
},
[onChange],
);
const uploadAvatar = useCallback((): void => {
fileInputRef.current?.click();
}, [fileInputRef]);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((newOpen: boolean) => {
setMenuOpen(newOpen);
}, []);
let avatarElement = (
<AccessibleButton
element="div"
onClick={uploadAvatar}
className="mx_AvatarSetting_avatarPlaceholder mx_AvatarSetting_avatarDisplay"
aria-labelledby={disabled ? undefined : a11yId}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled}
>
<BaseAvatar idName={placeholderId} name={placeholderName} size="90px" />
</AccessibleButton>
);
if (avatarURL) {
avatarElement = (
<AccessibleButton
element="img"
className="mx_AvatarSetting_avatarDisplay"
src={avatarURL}
alt={avatarAltText}
onClick={uploadAvatar}
// Inhibit tab stop as we have explicit upload/remove buttons
tabIndex={-1}
disabled={disabled}
/>
);
}
let uploadAvatarBtn: JSX.Element | undefined;
if (!disabled) {
const uploadButtonClasses = classNames("mx_AvatarSetting_uploadButton", {
mx_AvatarSetting_uploadButton_active: menuOpen,
});
uploadAvatarBtn = (
<div className={uploadButtonClasses}>
<EditIcon width="20px" height="20px" />
</div>
);
}
const content = (
<div className="mx_AvatarSetting_avatar" role="group" aria-label={avatarAltText}>
{avatarElement}
{uploadAvatarBtn}
</div>
);
if (disabled) {
return content;
}
return (
<>
<AvatarSettingContextMenu
trigger={content}
onUploadSelect={uploadAvatar}
onRemoveSelect={removeAvatar}
menuOpen={menuOpen}
onOpenChange={onOpenChange}
/>
<input
type="file"
style={{ display: "none" }}
ref={fileInputRef}
onClick={chromeFileInputFix}
onChange={onFileChanged}
accept="image/*"
alt={_t("action|upload")}
/>
</>
);
};
export default AvatarSetting;

View file

@ -0,0 +1,191 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
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 { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { Pill, PillType } from "../elements/Pill";
import { makeUserPermalink } from "../../../utils/permalinks/Permalinks";
import BaseAvatar from "../avatars/BaseAvatar";
import SettingsStore from "../../../settings/SettingsStore";
import { isUrlPermitted } from "../../../HtmlUtils";
import { mediaFromMxc } from "../../../customisations/Media";
interface IProps {
ev: MatrixEvent;
room: Room;
}
/**
* This should match https://github.com/matrix-org/matrix-doc/blob/hs/msc-bridge-inf/proposals/2346-bridge-info-state-event.md#mbridge
*/
interface IBridgeStateEvent {
bridgebot: string;
creator?: string;
protocol: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
network?: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
channel: {
id: string;
displayname?: string;
// eslint-disable-next-line camelcase
avatar_url?: string;
// eslint-disable-next-line camelcase
external_url?: string;
};
}
export default class BridgeTile extends React.PureComponent<IProps> {
public render(): React.ReactNode {
const content: IBridgeStateEvent = this.props.ev.getContent();
// Validate
if (!content.channel?.id || !content.protocol?.id) {
logger.warn(`Bridge info event ${this.props.ev.getId()} has missing content. Tile will not render`);
return null;
}
if (!content.bridgebot) {
// Bridgebot was not required previously, so in order to not break rooms we are allowing
// the sender to be used in place. When the proposal is merged, this should be removed.
logger.warn(
`Bridge info event ${this.props.ev.getId()} does not provide a 'bridgebot' key which` +
"is deprecated behaviour. Using sender for now.",
);
content.bridgebot = this.props.ev.getSender()!;
}
const { channel, network, protocol } = content;
const protocolName = protocol.displayname || protocol.id;
const channelName = channel.displayname || channel.id;
let creator: JSX.Element | undefined;
if (content.creator) {
creator = (
<li>
{_t(
"labs|bridge_state_creator",
{},
{
user: () => (
<Pill
type={PillType.UserMention}
room={this.props.room}
url={makeUserPermalink(content.creator!)}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>
),
},
)}
</li>
);
}
const bot = (
<li>
{_t(
"labs|bridge_state_manager",
{},
{
user: () => (
<Pill
type={PillType.UserMention}
room={this.props.room}
url={makeUserPermalink(content.bridgebot)}
shouldShowPillAvatar={SettingsStore.getValue("Pill.shouldShowPillAvatar")}
/>
),
},
)}
</li>
);
let networkIcon;
if (protocol.avatar_url) {
const avatarUrl = mediaFromMxc(protocol.avatar_url).getSquareThumbnailHttp(64) ?? undefined;
networkIcon = (
<BaseAvatar
className="mx_RoomSettingsDialog_protocolIcon"
size="48px"
name={protocolName}
idName={protocolName}
url={avatarUrl}
/>
);
} else {
networkIcon = <div className="mx_RoomSettingsDialog_noProtocolIcon" />;
}
let networkItem: ReactNode | undefined;
if (network) {
const networkName = network.displayname || network.id;
let networkLink = <span>{networkName}</span>;
if (typeof network.external_url === "string" && isUrlPermitted(network.external_url)) {
networkLink = (
<a href={network.external_url} target="_blank" rel="noreferrer noopener">
{networkName}
</a>
);
}
networkItem = _t(
"labs|bridge_state_workspace",
{},
{
networkLink: () => networkLink,
},
);
}
let channelLink = <span>{channelName}</span>;
if (typeof channel.external_url === "string" && isUrlPermitted(channel.external_url)) {
channelLink = (
<a href={channel.external_url} target="_blank" rel="noreferrer noopener">
{channelName}
</a>
);
}
const id = this.props.ev.getId();
return (
<li key={id} className="mx_RoomSettingsDialog_BridgeList_listItem">
<div className="mx_RoomSettingsDialog_column_icon">{networkIcon}</div>
<div className="mx_RoomSettingsDialog_column_data">
<h3 className="mx_RoomSettingsDialog_column_data_protocolName">{protocolName}</h3>
<p className="mx_RoomSettingsDialog_column_data_details mx_RoomSettingsDialog_workspace_channel_details">
{networkItem}
<span className="mx_RoomSettingsDialog_channel">
{_t(
"labs|bridge_state_channel",
{},
{
channelLink: () => channelLink,
},
)}
</span>
</p>
<ul className="mx_RoomSettingsDialog_column_data_metadata mx_RoomSettingsDialog_metadata">
{creator} {bot}
</ul>
</div>
</li>
);
}
}

View file

@ -0,0 +1,382 @@
/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket 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 from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import Field from "../elements/Field";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton, { AccessibleButtonKind } from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { UserFriendlyError, _t, _td } from "../../../languageHandler";
import Modal from "../../../Modal";
import PassphraseField from "../auth/PassphraseField";
import { PASSWORD_MIN_SCORE } from "../auth/RegistrationForm";
import SetEmailDialog from "../dialogs/SetEmailDialog";
const FIELD_OLD_PASSWORD = "field_old_password";
const FIELD_NEW_PASSWORD = "field_new_password";
const FIELD_NEW_PASSWORD_CONFIRM = "field_new_password_confirm";
type FieldType = typeof FIELD_OLD_PASSWORD | typeof FIELD_NEW_PASSWORD | typeof FIELD_NEW_PASSWORD_CONFIRM;
enum Phase {
Edit = "edit",
Uploading = "uploading",
Error = "error",
}
interface IProps {
onFinished: (outcome: { didSetEmail?: boolean }) => void;
onError: (error: Error) => void;
rowClassName?: string;
buttonClassName?: string;
buttonKind?: AccessibleButtonKind;
buttonLabel?: string;
confirm?: boolean;
// Whether to autoFocus the new password input
autoFocusNewPasswordInput?: boolean;
className?: string;
shouldAskForEmail?: boolean;
}
interface IState {
fieldValid: Partial<Record<FieldType, boolean>>;
phase: Phase;
oldPassword: string;
newPassword: string;
newPasswordConfirm: string;
}
export default class ChangePassword extends React.Component<IProps, IState> {
private [FIELD_OLD_PASSWORD]: Field | null = null;
private [FIELD_NEW_PASSWORD]: Field | null = null;
private [FIELD_NEW_PASSWORD_CONFIRM]: Field | null = null;
public static defaultProps: Partial<IProps> = {
onFinished() {},
onError() {},
confirm: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
fieldValid: {},
phase: Phase.Edit,
oldPassword: "",
newPassword: "",
newPasswordConfirm: "",
};
}
private async onChangePassword(oldPassword: string, newPassword: string): Promise<void> {
const cli = MatrixClientPeg.safeGet();
this.changePassword(cli, oldPassword, newPassword);
}
private changePassword(cli: MatrixClient, oldPassword: string, newPassword: string): void {
const authDict = {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: cli.credentials.userId,
},
password: oldPassword,
};
this.setState({
phase: Phase.Uploading,
});
cli.setPassword(authDict, newPassword, false)
.then(
() => {
if (this.props.shouldAskForEmail) {
return this.optionallySetEmail().then((confirmed) => {
this.props.onFinished({
didSetEmail: confirmed,
});
});
} else {
this.props.onFinished({});
}
},
(err) => {
if (err instanceof Error) {
this.props.onError(err);
} else {
this.props.onError(
new UserFriendlyError("auth|change_password_error", {
error: String(err),
cause: undefined,
}),
);
}
},
)
.finally(() => {
this.setState({
phase: Phase.Edit,
oldPassword: "",
newPassword: "",
newPasswordConfirm: "",
});
});
}
/**
* Checks the `newPass` and throws an error if it is unacceptable.
* @param oldPass The old password
* @param newPass The new password that the user is trying to be set
* @param confirmPass The confirmation password where the user types the `newPass`
* again for confirmation and should match the `newPass` before we accept their new
* password.
*/
private checkPassword(oldPass: string, newPass: string, confirmPass: string): void {
if (newPass !== confirmPass) {
throw new UserFriendlyError("auth|change_password_mismatch");
} else if (!newPass || newPass.length === 0) {
throw new UserFriendlyError("auth|change_password_empty");
}
}
private optionallySetEmail(): Promise<boolean> {
// Ask for an email otherwise the user has no way to reset their password
const modal = Modal.createDialog(SetEmailDialog, {
title: _t("auth|set_email_prompt"),
});
return modal.finished.then(([confirmed]) => !!confirmed);
}
private markFieldValid(fieldID: FieldType, valid?: boolean): void {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
}
private onChangeOldPassword = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
oldPassword: ev.target.value,
});
};
private onOldPasswordValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validateOldPasswordRules(fieldState);
this.markFieldValid(FIELD_OLD_PASSWORD, result.valid);
return result;
};
private validateOldPasswordRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("auth|change_password_empty"),
},
],
});
private onChangeNewPassword = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPassword: ev.target.value,
});
};
private onNewPasswordValidate = (result: IValidationResult): void => {
this.markFieldValid(FIELD_NEW_PASSWORD, result.valid);
};
private onChangeNewPasswordConfirm = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPasswordConfirm: ev.target.value,
});
};
private onNewPasswordConfirmValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid);
return result;
};
private validatePasswordConfirmRules = withValidation<this>({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("auth|change_password_confirm_label"),
},
{
key: "match",
test({ value }) {
return !value || value === this.state.newPassword;
},
invalid: () => _t("auth|change_password_confirm_invalid"),
},
],
});
private onClickChange = async (ev: React.MouseEvent | React.FormEvent): Promise<void> => {
ev.preventDefault();
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
const oldPassword = this.state.oldPassword;
const newPassword = this.state.newPassword;
const confirmPassword = this.state.newPasswordConfirm;
try {
// TODO: We can remove this check (but should add some Playwright tests to
// sanity check this flow). This logic is redundant with the input field
// validation we do and `verifyFieldsBeforeSubmit()` above. See
// https://github.com/matrix-org/matrix-react-sdk/pull/10615#discussion_r1167364214
this.checkPassword(oldPassword, newPassword, confirmPassword);
return this.onChangePassword(oldPassword, newPassword);
} catch (err) {
if (err instanceof Error) {
this.props.onError(err);
} else {
this.props.onError(
new UserFriendlyError("auth|change_password_error", {
error: String(err),
cause: undefined,
}),
);
}
}
};
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder: FieldType[] = [
FIELD_OLD_PASSWORD,
FIELD_NEW_PASSWORD,
FIELD_NEW_PASSWORD_CONFIRM,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise<void>((resolve) => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
private allFieldsValid(): boolean {
return Object.values(this.state.fieldValid).every(Boolean);
}
private findFirstInvalidField(fieldIDs: FieldType[]): Field | null {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
}
public render(): React.ReactNode {
const rowClassName = this.props.rowClassName;
const buttonClassName = this.props.buttonClassName;
switch (this.state.phase) {
case Phase.Edit:
return (
<form className={this.props.className} onSubmit={this.onClickChange}>
<div className={rowClassName}>
<Field
ref={(field) => (this[FIELD_OLD_PASSWORD] = field)}
type="password"
label={_t("auth|change_password_current_label")}
value={this.state.oldPassword}
onChange={this.onChangeOldPassword}
onValidate={this.onOldPasswordValidate}
/>
</div>
<div className={rowClassName}>
<PassphraseField
fieldRef={(field) => (this[FIELD_NEW_PASSWORD] = field)}
type="password"
label={_td("auth|change_password_new_label")}
minScore={PASSWORD_MIN_SCORE}
value={this.state.newPassword}
autoFocus={this.props.autoFocusNewPasswordInput}
onChange={this.onChangeNewPassword}
onValidate={this.onNewPasswordValidate}
autoComplete="new-password"
/>
</div>
<div className={rowClassName}>
<Field
ref={(field) => (this[FIELD_NEW_PASSWORD_CONFIRM] = field)}
type="password"
label={_t("auth|change_password_confirm_label")}
value={this.state.newPasswordConfirm}
onChange={this.onChangeNewPasswordConfirm}
onValidate={this.onNewPasswordConfirmValidate}
autoComplete="new-password"
/>
</div>
<AccessibleButton
className={buttonClassName}
kind={this.props.buttonKind}
onClick={this.onClickChange}
>
{this.props.buttonLabel || _t("auth|change_password_action")}
</AccessibleButton>
</form>
);
case Phase.Uploading:
return (
<div className="mx_Dialog_content">
<Spinner />
</div>
);
}
}
}

View file

@ -0,0 +1,312 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { ClientEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
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<{}, IState> {
private unmounted = false;
public constructor(props: {}) {
super(props);
this.state = {
error: false,
};
}
public componentDidMount(): void {
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}
</>
);
}
}

View file

@ -0,0 +1,145 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React 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 { MatrixClientPeg } from "../../../MatrixClientPeg";
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";
interface IProps {}
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<IProps, IState> {
public constructor(props: IProps) {
super(props);
const client = MatrixClientPeg.safeGet();
const crypto = client.getCrypto();
if (!crypto) {
this.state = { deviceIdentityKey: null };
} else {
this.state = { deviceIdentityKey: undefined };
crypto
.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 = MatrixClientPeg.safeGet();
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.createDialogAsync(
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise<
typeof ExportE2eKeysDialog
>,
{ matrixClient: MatrixClientPeg.safeGet() },
);
};
private onImportE2eKeysClicked = (): void => {
Modal.createDialogAsync(
import("../../../async-components/views/dialogs/security/ImportE2eKeysDialog") as unknown as Promise<
typeof ImportE2eKeysDialog
>,
{ matrixClient: MatrixClientPeg.safeGet() },
);
};
private updateBlacklistDevicesFlag = (checked: boolean): void => {
MatrixClientPeg.safeGet().setGlobalBlacklistUnverifiedDevices(checked);
};
}

View file

@ -0,0 +1,242 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
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 { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import { formatBytes, formatCountLong } from "../../../utils/FormattingUtils";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
import { SettingLevel } from "../../../settings/SettingLevel";
import SeshatResetDialog from "../dialogs/SeshatResetDialog";
import InlineSpinner from "../elements/InlineSpinner";
import ExternalLink from "../elements/ExternalLink";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState {
enabling: boolean;
eventIndexSize: number;
roomCount: number;
eventIndexingEnabled: boolean;
}
export default class EventIndexPanel extends React.Component<{}, IState> {
public constructor(props: {}) {
super(props);
this.state = {
enabling: false,
eventIndexSize: 0,
roomCount: 0,
eventIndexingEnabled: SettingsStore.getValueAt(SettingLevel.DEVICE, "enableEventIndexing"),
};
}
public updateCurrentRoom = async (): Promise<void> => {
const eventIndex = EventIndexPeg.get();
const stats = await eventIndex?.getStats().catch(() => {});
// This call may fail if sporadically, not a huge issue as we will try later again and probably succeed.
if (!stats) return;
this.setState({
eventIndexSize: stats.size,
roomCount: stats.roomCount,
});
};
public componentWillUnmount(): void {
const eventIndex = EventIndexPeg.get();
if (eventIndex !== null) {
eventIndex.removeListener("changedCheckpoint", this.updateCurrentRoom);
}
}
public componentDidMount(): void {
this.updateState();
}
public async updateState(): Promise<void> {
const eventIndex = EventIndexPeg.get();
const eventIndexingEnabled = SettingsStore.getValueAt(SettingLevel.DEVICE, "enableEventIndexing");
const enabling = false;
let eventIndexSize = 0;
let roomCount = 0;
if (eventIndex !== null) {
eventIndex.on("changedCheckpoint", this.updateCurrentRoom);
const stats = await eventIndex.getStats().catch(() => {});
// This call may fail if sporadically, not a huge issue as we
// will try later again in the updateCurrentRoom call and
// probably succeed.
if (stats) {
eventIndexSize = stats.size;
roomCount = stats.roomCount;
}
}
this.setState({
enabling,
eventIndexSize,
roomCount,
eventIndexingEnabled,
});
}
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"),
{
onFinished: () => {},
},
null,
/* priority = */ false,
/* static = */ true,
);
};
private onEnable = async (): Promise<void> => {
this.setState({
enabling: true,
});
await EventIndexPeg.initEventIndex();
await EventIndexPeg.get()?.addInitialCheckpoints();
EventIndexPeg.get()?.startCrawler();
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, true);
await this.updateState();
};
private confirmEventStoreReset = (): void => {
const { close } = Modal.createDialog(SeshatResetDialog, {
onFinished: async (success): Promise<void> => {
if (success) {
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
await this.onEnable();
close();
}
},
});
};
public render(): React.ReactNode {
let eventIndexingSettings: JSX.Element | undefined;
const brand = SdkConfig.get().brand;
if (EventIndexPeg.get() !== null) {
eventIndexingSettings = (
<>
<SettingsSubsectionText>
{_t("settings|security|message_search_enabled", {
size: formatBytes(this.state.eventIndexSize, 0),
// This drives the singular / plural string
// selection for "room" / "rooms" only.
count: this.state.roomCount,
rooms: formatCountLong(this.state.roomCount),
})}
</SettingsSubsectionText>
<AccessibleButton kind="primary" onClick={this.onManage}>
{_t("action|manage")}
</AccessibleButton>
</>
);
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = (
<>
<SettingsSubsectionText>{_t("settings|security|message_search_disabled")}</SettingsSubsectionText>
<div>
<AccessibleButton kind="primary" disabled={this.state.enabling} onClick={this.onEnable}>
{_t("action|enable")}
</AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />}
</div>
</>
);
} else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) {
const nativeLink =
"https://github.com/vector-im/element-desktop/blob/develop/" +
"docs/native-node-modules.md#" +
"adding-seshat-for-search-in-e2e-encrypted-rooms";
eventIndexingSettings = (
<SettingsSubsectionText>
{_t(
"settings|security|message_search_unsupported",
{
brand,
},
{
nativeLink: (sub) => (
<ExternalLink href={nativeLink} target="_blank" rel="noreferrer noopener">
{sub}
</ExternalLink>
),
},
)}
</SettingsSubsectionText>
);
} else if (!EventIndexPeg.platformHasSupport()) {
eventIndexingSettings = (
<SettingsSubsectionText>
{_t(
"settings|security|message_search_unsupported_web",
{
brand,
},
{
desktopLink: (sub) => (
<ExternalLink
href="https://element.io/get-started"
target="_blank"
rel="noreferrer noopener"
>
{sub}
</ExternalLink>
),
},
)}
</SettingsSubsectionText>
);
} else {
eventIndexingSettings = (
<>
<SettingsSubsectionText>
{this.state.enabling ? <InlineSpinner /> : _t("settings|security|message_search_failed")}
</SettingsSubsectionText>
{EventIndexPeg.error && (
<SettingsSubsectionText>
<details>
<summary>{_t("common|advanced")}</summary>
<code>
{EventIndexPeg.error instanceof Error
? EventIndexPeg.error.message
: _t("error|unknown")}
</code>
<p>
<AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}>
{_t("action|reset")}
</AccessibleButton>
</p>
</details>
</SettingsSubsectionText>
)}
</>
);
}
return eventIndexingSettings;
}
}

View file

@ -0,0 +1,138 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
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 EventTilePreview from "../elements/EventTilePreview";
import SettingsStore from "../../../settings/SettingsStore";
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 Field from "../elements/Field";
import { FontWatcher } from "../../../settings/watchers/FontWatcher";
interface IProps {}
interface IState {
browserFontSize: number;
// String displaying the current selected fontSize.
// Needs to be string for things like '1.' without
// trailing 0s.
fontSizeDelta: number;
useCustomFontSize: boolean;
layout: Layout;
// User profile data for the message preview
userId?: string;
displayName?: string;
avatarUrl?: string;
}
export default class FontScalingPanel extends React.Component<IProps, IState> {
private readonly MESSAGE_PREVIEW_TEXT = _t("common|preview_message");
/**
* Font sizes available (in px)
*/
private readonly sizes = [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36];
private layoutWatcherRef?: string;
private unmounted = false;
public constructor(props: IProps) {
super(props);
this.state = {
fontSizeDelta: SettingsStore.getValue<number>("fontSizeDelta", null),
browserFontSize: FontWatcher.getBrowserDefaultFontSize(),
useCustomFontSize: SettingsStore.getValue("useCustomFontSize"),
layout: SettingsStore.getValue("layout"),
};
}
public async componentDidMount(): Promise<void> {
// Fetch the current user profile for the message preview
const client = MatrixClientPeg.safeGet();
const userId = client.getSafeUserId();
const profileInfo = await client.getProfileInfo(userId);
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, () => {
// Update the layout for the preview window according to the user selection
const value = SettingsStore.getValue("layout");
if (this.state.layout !== value) {
this.setState({
layout: value,
});
}
});
if (this.unmounted) return;
this.setState({
userId,
displayName: profileInfo.displayname,
avatarUrl: profileInfo.avatar_url,
});
}
public componentWillUnmount(): void {
this.unmounted = true;
if (this.layoutWatcherRef) {
SettingsStore.unwatchSetting(this.layoutWatcherRef);
}
}
/**
* Save the new font size
* @param delta
*/
private onFontSizeChanged = async (delta: string): Promise<void> => {
const parsedDelta = parseInt(delta, 10) || 0;
this.setState({ fontSizeDelta: parsedDelta });
await SettingsStore.setValue("fontSizeDelta", null, SettingLevel.DEVICE, parsedDelta);
};
/**
* Compute the difference between the selected font size and the browser font size
* @param fontSize
*/
private computeDeltaFontSize = (fontSize: number): number => {
return fontSize - this.state.browserFontSize;
};
public render(): React.ReactNode {
return (
<SettingsSubsection
heading={_t("settings|appearance|font_size")}
stretchContent
data-testid="mx_FontScalingPanel"
>
<Field
element="select"
className="mx_FontScalingPanel_Dropdown"
label={_t("settings|appearance|font_size")}
value={this.state.fontSizeDelta.toString()}
onChange={(e) => this.onFontSizeChanged(e.target.value)}
>
{this.sizes.map((size) => (
<option key={size} value={this.computeDeltaFontSize(size)}>
{size === this.state.browserFontSize
? _t("settings|appearance|font_size_default", { fontSize: size })
: size}
</option>
))}
</Field>
<EventTilePreview
className="mx_FontScalingPanel_preview"
message={this.MESSAGE_PREVIEW_TEXT}
layout={this.state.layout}
userId={this.state.userId}
displayName={this.state.displayName}
avatarUrl={this.state.avatarUrl}
/>
</SettingsSubsection>
);
}
}

View file

@ -0,0 +1,73 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SettingsStore from "../../../settings/SettingsStore";
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";
interface IProps {
// none
}
interface IState {
size: ImageSize;
}
export default class ImageSizePanel extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
size: SettingsStore.getValue("Images.size"),
};
}
private onSizeChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
const newSize = ev.target.value as ImageSize;
this.setState({ size: newSize });
// noinspection JSIgnoredPromiseFromCall
SettingsStore.setValue("Images.size", null, SettingLevel.ACCOUNT, newSize);
};
public render(): React.ReactNode {
return (
<SettingsSubsection heading={_t("settings|appearance|timeline_image_size")}>
<div className="mx_ImageSizePanel_radios">
<label>
<div className="mx_ImageSizePanel_size mx_ImageSizePanel_sizeDefault" />
<StyledRadioButton
name="image_size"
value={ImageSize.Normal}
checked={this.state.size === ImageSize.Normal}
onChange={this.onSizeChange}
>
{_t("settings|appearance|image_size_default")}
</StyledRadioButton>
</label>
<label>
<div className="mx_ImageSizePanel_size mx_ImageSizePanel_sizeLarge" />
<StyledRadioButton
name="image_size"
value={ImageSize.Large}
checked={this.state.size === ImageSize.Large}
onChange={this.onSizeChange}
>
{_t("settings|appearance|image_size_large")}
</StyledRadioButton>
</label>
</div>
</SettingsSubsection>
);
}
}

View file

@ -0,0 +1,101 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
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 { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import Spinner from "../elements/Spinner";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import Heading from "../typography/Heading";
interface IProps {
// false to display an error saying that we couldn't connect to the integration manager
connected?: boolean;
// true to display a loading spinner
loading?: boolean;
// The source URL to load
url?: string;
// callback when the manager is dismissed
onFinished: () => void;
}
interface IState {
errored: boolean;
}
export default class IntegrationManager extends React.Component<IProps, IState> {
private dispatcherRef?: string;
public static defaultProps: Partial<IProps> = {
connected: true,
loading: false,
};
public state = {
errored: false,
};
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown);
}
public componentWillUnmount(): void {
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
}
private onKeyDown = (ev: KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Escape:
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
break;
}
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === "close_scalar") {
this.props.onFinished();
}
};
private onError = (): void => {
this.setState({ errored: true });
};
public render(): React.ReactNode {
if (this.props.loading) {
return (
<div className="mx_IntegrationManager_loading">
<Heading size="3">{_t("integration_manager|connecting")}</Heading>
<Spinner />
</div>
);
}
if (!this.props.connected || this.state.errored) {
return (
<div className="mx_IntegrationManager_error">
<Heading size="3">{_t("integration_manager|error_connecting_heading")}</Heading>
<p>{_t("integration_manager|error_connecting")}</p>
</div>
);
}
return <iframe title={_t("common|integration_manager")} src={this.props.url} onError={this.onError} />;
}
}

View file

@ -0,0 +1,407 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, useEffect, useState } from "react";
import { JoinRule, RestrictedAllowType, Room, EventType, Visibility } from "matrix-js-sdk/src/matrix";
import { RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
import StyledRadioGroup, { IDefinition } from "../elements/StyledRadioGroup";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog, { IFinishedOpts } from "../dialogs/RoomUpgradeWarningDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
import dis from "../../../dispatcher/dispatcher";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { doesRoomVersionSupport, PreferredRoomVersions } from "../../../utils/PreferredRoomVersions";
import SettingsStore from "../../../settings/SettingsStore";
import LabelledCheckbox from "../elements/LabelledCheckbox";
export interface JoinRuleSettingsProps {
room: Room;
promptUpgrade?: boolean;
closeSettingsFn(): void;
onError(error: unknown): void;
beforeChange?(joinRule: JoinRule): Promise<boolean>; // if returns false then aborts the change
aliasWarning?: ReactNode;
}
const JoinRuleSettings: React.FC<JoinRuleSettingsProps> = ({
room,
promptUpgrade,
aliasWarning,
onError,
beforeChange,
closeSettingsFn,
}) => {
const cli = room.client;
const askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
const roomSupportsKnock = doesRoomVersionSupport(room.getVersion(), PreferredRoomVersions.KnockRooms);
const preferredKnockVersion = !roomSupportsKnock && promptUpgrade ? PreferredRoomVersions.KnockRooms : undefined;
const roomSupportsRestricted = doesRoomVersionSupport(room.getVersion(), PreferredRoomVersions.RestrictedRooms);
const preferredRestrictionVersion =
!roomSupportsRestricted && promptUpgrade ? PreferredRoomVersions.RestrictedRooms : undefined;
const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli);
const [content, setContent] = useLocalEcho<RoomJoinRulesEventContent | undefined, RoomJoinRulesEventContent>(
() => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(),
(content) => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""),
onError,
);
const { join_rule: joinRule = JoinRule.Invite } = content || {};
const restrictedAllowRoomIds =
joinRule === JoinRule.Restricted
? content?.allow?.filter((o) => o.type === RestrictedAllowType.RoomMembership).map((o) => o.room_id)
: undefined;
const [isPublicKnockRoom, setIsPublicKnockRoom] = useState(false);
useEffect(() => {
if (joinRule === JoinRule.Knock) {
cli.getRoomDirectoryVisibility(room.roomId)
.then(({ visibility }) => setIsPublicKnockRoom(visibility === Visibility.Public))
.catch(onError);
}
}, [cli, joinRule, onError, room.roomId]);
const onIsPublicKnockRoomChange = (checked: boolean): void => {
cli.setRoomDirectoryVisibility(room.roomId, checked ? Visibility.Public : Visibility.Private)
.then(() => setIsPublicKnockRoom(checked))
.catch(onError);
};
const editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
let selected = restrictedAllowRoomIds;
if (!selected?.length && SpaceStore.instance.activeSpaceRoom) {
selected = [SpaceStore.instance.activeSpaceRoom.roomId];
}
const { finished } = Modal.createDialog(
ManageRestrictedJoinRuleDialog,
{
room,
selected,
},
"mx_ManageRestrictedJoinRuleDialog_wrapper",
);
const [roomIds] = await finished;
return roomIds;
};
const upgradeRequiredDialog = (targetVersion: string, description?: ReactNode): void => {
Modal.createDialog(RoomUpgradeWarningDialog, {
roomId: room.roomId,
targetVersion,
description,
doUpgrade: async (
opts: IFinishedOpts,
fn: (progressText: string, progress: number, total: number) => void,
): Promise<void> => {
const roomId = await upgradeRoom(room, targetVersion, opts.invite, true, true, true, (progress) => {
const total = 2 + progress.updateSpacesTotal + progress.inviteUsersTotal;
if (!progress.roomUpgraded) {
fn(_t("room_settings|security|join_rule_upgrade_upgrading_room"), 0, total);
} else if (!progress.roomSynced) {
fn(_t("room_settings|security|join_rule_upgrade_awaiting_room"), 1, total);
} else if (
progress.inviteUsersProgress !== undefined &&
progress.inviteUsersProgress < progress.inviteUsersTotal
) {
fn(
_t("room_settings|security|join_rule_upgrade_sending_invites", {
progress: progress.inviteUsersProgress,
count: progress.inviteUsersTotal,
}),
2 + progress.inviteUsersProgress,
total,
);
} else if (
progress.updateSpacesProgress !== undefined &&
progress.updateSpacesProgress < progress.updateSpacesTotal
) {
fn(
_t("room_settings|security|join_rule_upgrade_updating_spaces", {
progress: progress.updateSpacesProgress,
count: progress.updateSpacesTotal,
}),
2 + (progress.inviteUsersProgress ?? 0) + progress.updateSpacesProgress,
total,
);
}
});
closeSettingsFn();
// switch to the new room in the background
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: undefined, // other
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.Security,
});
},
});
};
const upgradeRequiredPill = (
<span className="mx_JoinRuleSettings_upgradeRequired">
{_t("room_settings|security|join_rule_upgrade_required")}
</span>
);
const definitions: IDefinition<JoinRule>[] = [
{
value: JoinRule.Invite,
label: _t("room_settings|security|join_rule_invite"),
description: _t("room_settings|security|join_rule_invite_description"),
checked:
joinRule === JoinRule.Invite || (joinRule === JoinRule.Restricted && !restrictedAllowRoomIds?.length),
},
{
value: JoinRule.Public,
label: _t("common|public"),
description: (
<>
{_t("room_settings|security|join_rule_public_description")}
{aliasWarning}
</>
),
},
];
if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) {
let description;
if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) {
// only show the first 4 spaces we know about, so that the UI doesn't grow out of proportion there are lots.
const shownSpaces = restrictedAllowRoomIds
.map((roomId) => cli.getRoom(roomId))
.filter((room) => room?.isSpaceRoom())
.slice(0, 4) as Room[];
let moreText;
if (shownSpaces.length < restrictedAllowRoomIds.length) {
if (shownSpaces.length > 0) {
moreText = _t("room_settings|security|join_rule_restricted_n_more", {
count: restrictedAllowRoomIds.length - shownSpaces.length,
});
} else {
moreText = _t("room_settings|security|join_rule_restricted_summary", {
count: restrictedAllowRoomIds.length,
});
}
}
const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]): void => {
if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return;
if (!newAllowRoomIds.length) {
setContent({
join_rule: JoinRule.Invite,
});
return;
}
setContent({
join_rule: JoinRule.Restricted,
allow: newAllowRoomIds.map((roomId) => ({
type: RestrictedAllowType.RoomMembership,
room_id: roomId,
})),
});
};
const onEditRestrictedClick = async (): Promise<void> => {
const restrictedAllowRoomIds = await editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
if (restrictedAllowRoomIds.length > 0) {
onRestrictedRoomIdsChange(restrictedAllowRoomIds);
} else {
onChange(JoinRule.Invite);
}
};
description = (
<div>
<span>
{_t(
"room_settings|security|join_rule_restricted_description",
{},
{
a: (sub) => (
<AccessibleButton
disabled={disabled}
onClick={onEditRestrictedClick}
kind="link_inline"
>
{sub}
</AccessibleButton>
),
},
)}
</span>
<div className="mx_JoinRuleSettings_spacesWithAccess">
<h4>{_t("room_settings|security|join_rule_restricted_description_spaces")}</h4>
{shownSpaces.map((room) => {
return (
<span key={room.roomId}>
<RoomAvatar room={room} size="32px" />
{room.name}
</span>
);
})}
{moreText && <span>{moreText}</span>}
</div>
</div>
);
} else if (SpaceStore.instance.activeSpaceRoom) {
description = _t(
"room_settings|security|join_rule_restricted_description_active_space",
{},
{
spaceName: () => <strong>{SpaceStore.instance.activeSpaceRoom!.name}</strong>,
},
);
} else {
description = _t("room_settings|security|join_rule_restricted_description_prompt");
}
definitions.splice(1, 0, {
value: JoinRule.Restricted,
label: (
<>
{_t("room_settings|security|join_rule_restricted")}
{preferredRestrictionVersion && upgradeRequiredPill}
</>
),
description,
// if there are 0 allowed spaces then render it as invite only instead
checked: joinRule === JoinRule.Restricted && !!restrictedAllowRoomIds?.length,
});
}
if (askToJoinEnabled && (roomSupportsKnock || preferredKnockVersion)) {
definitions.push({
value: JoinRule.Knock,
label: (
<>
{_t("room_settings|security|join_rule_knock")}
{preferredKnockVersion && upgradeRequiredPill}
</>
),
description: (
<>
{_t("room_settings|security|join_rule_knock_description")}
<LabelledCheckbox
className="mx_JoinRuleSettings_labelledCheckbox"
disabled={joinRule !== JoinRule.Knock}
label={
room.isSpaceRoom()
? _t("room_settings|security|publish_space")
: _t("room_settings|security|publish_room")
}
onChange={onIsPublicKnockRoomChange}
value={isPublicKnockRoom}
/>
</>
),
});
}
const onChange = async (joinRule: JoinRule): Promise<void> => {
const beforeJoinRule = content?.join_rule;
let restrictedAllowRoomIds: string[] | undefined;
if (joinRule === JoinRule.Restricted) {
if (beforeJoinRule === JoinRule.Restricted || roomSupportsRestricted) {
// Have the user pick which spaces to allow joins from
restrictedAllowRoomIds = await editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
} else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion;
let warning: JSX.Element | undefined;
const userId = cli.getUserId()!;
const unableToUpdateSomeParents = Array.from(SpaceStore.instance.getKnownParents(room.roomId)).some(
(roomId) => !cli.getRoom(roomId)?.currentState.maySendStateEvent(EventType.SpaceChild, userId),
);
if (unableToUpdateSomeParents) {
warning = <strong>{_t("room_settings|security|join_rule_restricted_upgrade_warning")}</strong>;
}
upgradeRequiredDialog(
targetVersion,
<>
{_t("room_settings|security|join_rule_restricted_upgrade_description")}
{warning}
</>,
);
return;
}
// when setting to 0 allowed rooms/spaces set to invite only instead as per the note
if (!restrictedAllowRoomIds?.length) {
joinRule = JoinRule.Invite;
}
} else if (joinRule === JoinRule.Knock) {
if (preferredKnockVersion) {
upgradeRequiredDialog(preferredKnockVersion);
return;
}
}
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
if (beforeChange && !(await beforeChange(joinRule))) return;
const newContent: RoomJoinRulesEventContent = {
join_rule: joinRule,
};
// pre-set the accepted spaces with the currently viewed one as per the microcopy
if (joinRule === JoinRule.Restricted) {
newContent.allow = restrictedAllowRoomIds?.map((roomId) => ({
type: RestrictedAllowType.RoomMembership,
room_id: roomId,
}));
}
setContent(newContent);
};
return (
<StyledRadioGroup
name="joinRule"
value={joinRule}
onChange={onChange}
definitions={definitions}
disabled={disabled}
className="mx_JoinRuleSettings_radioButton"
/>
);
};
export default JoinRuleSettings;

View file

@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
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 { ALTERNATE_KEY_NAME, KEY_ICON } from "../../../accessibility/KeyboardShortcuts";
import { KeyCombo } from "../../../KeyBindingsManager";
import { IS_MAC, Key } from "../../../Keyboard";
import { _t } from "../../../languageHandler";
interface IKeyboardKeyProps {
name: string;
last?: boolean;
}
export const KeyboardKey: React.FC<IKeyboardKeyProps> = ({ name, last }) => {
const icon = KEY_ICON[name];
const alternateName = ALTERNATE_KEY_NAME[name];
return (
<React.Fragment>
<kbd> {icon || (alternateName && _t(alternateName)) || name} </kbd>
{!last && "+"}
</React.Fragment>
);
};
interface IKeyboardShortcutProps {
value: KeyCombo;
className?: string;
}
export const KeyboardShortcut: React.FC<IKeyboardShortcutProps> = ({ value, className = "mx_KeyboardShortcut" }) => {
if (!value) return null;
const modifiersElement: JSX.Element[] = [];
if (value.ctrlOrCmdKey) {
modifiersElement.push(<KeyboardKey key="ctrlOrCmdKey" name={IS_MAC ? Key.META : Key.CONTROL} />);
} else if (value.ctrlKey) {
modifiersElement.push(<KeyboardKey key="ctrlKey" name={Key.CONTROL} />);
} else if (value.metaKey) {
modifiersElement.push(<KeyboardKey key="metaKey" name={Key.META} />);
}
if (value.altKey) {
modifiersElement.push(<KeyboardKey key="altKey" name={Key.ALT} />);
}
if (value.shiftKey) {
modifiersElement.push(<KeyboardKey key="shiftKey" name={Key.SHIFT} />);
}
return (
<div className={className}>
{modifiersElement}
<KeyboardKey name={value.key} last />
</div>
);
};

View file

@ -0,0 +1,162 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* 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, { JSX, useEffect, useState } from "react";
import { Field, HelpMessage, InlineField, Label, RadioControl, Root, ToggleControl } from "@vector-im/compound-web";
import SettingsSubsection from "./shared/SettingsSubsection";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import { useSettingValue } from "../../../hooks/useSettings";
import { Layout } from "../../../settings/enums/Layout";
import EventTilePreview from "../elements/EventTilePreview";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
/**
* A section to switch between different message layouts.
*/
export function LayoutSwitcher(): JSX.Element {
return (
<SettingsSubsection heading={_t("common|message_layout")} legacy={false} data-testid="layoutPanel">
<LayoutSelector />
<ToggleCompactLayout />
</SettingsSubsection>
);
}
/**
* A selector to choose the layout of the messages.
*/
function LayoutSelector(): JSX.Element {
return (
<Root
className="mx_LayoutSwitcher_LayoutSelector"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newLayout = new FormData(evt.currentTarget).get("layout") as string | null;
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, newLayout);
}}
>
<LayoutRadio layout={Layout.Group} label={_t("common|modern")} />
<LayoutRadio layout={Layout.Bubble} label={_t("settings|appearance|layout_bubbles")} />
<LayoutRadio layout={Layout.IRC} label={_t("settings|appearance|layout_irc")} />
</Root>
);
}
/**
* A radio button to select a layout.
*/
interface LayoutRadioProps {
/**
* The value of the layout.
*/
layout: Layout;
/**
* The label to display for the layout.
*/
label: string;
}
/**
* A radio button to select a layout.
* @param layout
* @param label
*/
function LayoutRadio({ layout, label }: LayoutRadioProps): JSX.Element {
const currentLayout = useSettingValue<Layout>("layout");
const eventTileInfo = useEventTileInfo();
return (
<Field name="layout" className="mxLayoutSwitcher_LayoutSelector_LayoutRadio">
<Label aria-label={label}>
<div className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_inline">
<RadioControl name="layout" value={layout} defaultChecked={currentLayout === layout} />
<span>{label}</span>
</div>
<hr className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_separator" />
<EventTilePreview
message={_t("common|preview_message")}
layout={layout}
className="mxLayoutSwitcher_LayoutSelector_LayoutRadio_EventTilePreview"
{...eventTileInfo}
/>
</Label>
</Field>
);
}
type EventTileInfo = {
/**
* The ID of the user to display.
*/
userId: string;
/**
* The display name of the user to display.
*/
displayName?: string;
/**
* The avatar URL of the user to display.
*/
avatarUrl?: string;
};
/**
* Fetch the information to display in the event tile preview.
*/
function useEventTileInfo(): EventTileInfo {
const matrixClient = useMatrixClientContext();
const userId = matrixClient.getSafeUserId();
const [eventTileInfo, setEventTileInfo] = useState<EventTileInfo>({ userId });
useEffect(() => {
const run = async (): Promise<void> => {
const profileInfo = await matrixClient.getProfileInfo(userId);
setEventTileInfo({
userId,
displayName: profileInfo.displayname,
avatarUrl: profileInfo.avatar_url,
});
};
run();
}, [userId, matrixClient, setEventTileInfo]);
return eventTileInfo;
}
/**
* A toggleable setting to enable or disable the compact layout.
*/
function ToggleCompactLayout(): JSX.Element {
const compactLayoutEnabled = useSettingValue<boolean>("useCompactLayout");
const layout = useSettingValue<Layout>("layout");
return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("compactLayout") === "on";
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, checked);
}}
>
<InlineField
name="compactLayout"
control={
<ToggleControl
disabled={layout !== Layout.Group}
name="compactLayout"
defaultChecked={compactLayoutEnabled}
/>
}
>
<Label>{_t("settings|appearance|compact_layout")}</Label>
<HelpMessage>{_t("settings|appearance|compact_layout_description")}</HelpMessage>
</InlineField>
</Root>
);
}

View file

@ -0,0 +1,883 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2016-2022 The Matrix.org Foundation C.I.C.
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 {
IAnnotatedPushRule,
IPusher,
PushRuleAction,
PushRuleKind,
RuleId,
IThreepid,
ThreepidMedium,
LocalNotificationSettings,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import {
ContentRules,
IContentRules,
PushRuleVectorState,
VectorPushRulesDefinitions,
VectorState,
} from "../../../notifications";
import type { VectorPushRuleDefinition } from "../../../notifications";
import { _t, TranslatedString } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import { SettingLevel } from "../../../settings/SettingLevel";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../elements/AccessibleButton";
import TagComposer from "../elements/TagComposer";
import { objectClone } from "../../../utils/objects";
import { arrayDiff, filterBoolean } from "../../../utils/arrays";
import { clearAllNotifications, getLocalNotificationAccountDataEventType } from "../../../utils/notifications";
import {
updateExistingPushRulesWithActions,
updatePushRuleActions,
} from "../../../utils/pushRules/updatePushRuleActions";
import { Caption } from "../typography/Caption";
import { SettingsSubsectionHeading } from "./shared/SettingsSubsectionHeading";
import SettingsSubsection from "./shared/SettingsSubsection";
import { doesRoomHaveUnreadMessages } from "../../../Unread";
import SettingsFlag from "../elements/SettingsFlag";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
enum Phase {
Loading = "loading",
Ready = "ready",
Persisting = "persisting", // technically a meta-state for Ready, but whatever
// unrecoverable error - eg can't load push rules
Error = "error",
// error saving individual rule
SavingError = "savingError",
}
enum RuleClass {
Master = "master",
// The vector sections map approximately to UI sections
VectorGlobal = "vector_global",
VectorMentions = "vector_mentions",
VectorOther = "vector_other",
Other = "other", // unknown rules, essentially
}
const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component
const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions;
// This array doesn't care about categories: it's just used for a simple sort
const RULE_DISPLAY_ORDER: string[] = [
// Global
RuleId.DM,
RuleId.EncryptedDM,
RuleId.Message,
RuleId.EncryptedMessage,
// Mentions
RuleId.ContainsDisplayName,
RuleId.ContainsUserName,
RuleId.AtRoomNotification,
// Other
RuleId.InviteToSelf,
RuleId.IncomingCall,
RuleId.SuppressNotices,
RuleId.Tombstone,
];
interface IVectorPushRule {
ruleId: RuleId | typeof KEYWORD_RULE_ID | string;
rule?: IAnnotatedPushRule;
description: TranslatedString | string;
vectorState: VectorState;
// loudest vectorState of a rule and its synced rules
// undefined when rule has no synced rules
syncedVectorState?: VectorState;
}
interface IProps {}
interface IState {
phase: Phase;
// Optional stuff is required when `phase === Ready`
masterPushRule?: IAnnotatedPushRule;
vectorKeywordRuleInfo?: IContentRules;
vectorPushRules?: {
[category in RuleClass]?: IVectorPushRule[];
};
pushers?: IPusher[];
threepids?: IThreepid[];
deviceNotificationsEnabled: boolean;
desktopNotifications: boolean;
desktopShowBody: boolean;
audioNotifications: boolean;
clearingNotifications: boolean;
ruleIdsWithError: Record<RuleId | string, boolean>;
}
const findInDefaultRules = (
ruleId: RuleId | string,
defaultRules: {
[k in RuleClass]: IAnnotatedPushRule[];
},
): IAnnotatedPushRule | undefined => {
for (const category in defaultRules) {
const rule: IAnnotatedPushRule | undefined = defaultRules[category as RuleClass].find(
(rule) => rule.rule_id === ruleId,
);
if (rule) {
return rule;
}
}
};
// Vector notification states ordered by loudness in ascending order
const OrderedVectorStates = [VectorState.Off, VectorState.On, VectorState.Loud];
/**
* Find the 'loudest' vector state assigned to a rule
* and it's synced rules
* If rules have fallen out of sync,
* the loudest rule can determine the display value
* @param defaultRules
* @param rule - parent rule
* @param definition - definition of parent rule
* @returns VectorState - the maximum/loudest state for the parent and synced rules
*/
const maximumVectorState = (
defaultRules: {
[k in RuleClass]: IAnnotatedPushRule[];
},
rule: IAnnotatedPushRule,
definition: VectorPushRuleDefinition,
): VectorState | undefined => {
if (!definition.syncedRuleIds?.length) {
return undefined;
}
const vectorState = definition.syncedRuleIds.reduce<VectorState>((maxVectorState, ruleId) => {
// already set to maximum
if (maxVectorState === VectorState.Loud) {
return maxVectorState;
}
const syncedRule = findInDefaultRules(ruleId, defaultRules);
if (syncedRule) {
const syncedRuleVectorState = definition.ruleToVectorState(syncedRule);
// if syncedRule is 'louder' than current maximum
// set maximum to louder vectorState
if (
syncedRuleVectorState &&
OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)
) {
return syncedRuleVectorState;
}
}
return maxVectorState;
}, definition.ruleToVectorState(rule)!);
return vectorState;
};
const NotificationActivitySettings = (): JSX.Element => {
return (
<div>
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</div>
);
};
/**
* The old, deprecated notifications tab view, only displayed if the user has the labs flag disabled.
*/
export default class Notifications extends React.PureComponent<IProps, IState> {
private settingWatchers: string[];
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.Loading,
deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true,
desktopNotifications: SettingsStore.getValue("notificationsEnabled"),
desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"),
audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"),
clearingNotifications: false,
ruleIdsWithError: {},
};
this.settingWatchers = [
SettingsStore.watchSetting("notificationsEnabled", null, (...[, , , , value]) =>
this.setState({ desktopNotifications: value as boolean }),
),
SettingsStore.watchSetting("deviceNotificationsEnabled", null, (...[, , , , value]) => {
this.setState({ deviceNotificationsEnabled: value as boolean });
}),
SettingsStore.watchSetting("notificationBodyEnabled", null, (...[, , , , value]) =>
this.setState({ desktopShowBody: value as boolean }),
),
SettingsStore.watchSetting("audioNotificationsEnabled", null, (...[, , , , value]) =>
this.setState({ audioNotifications: value as boolean }),
),
];
}
private get isInhibited(): boolean {
// Caution: The master rule's enabled state is inverted from expectation. When
// the master rule is *enabled* it means all other rules are *disabled* (or
// inhibited). Conversely, when the master rule is *disabled* then all other rules
// are *enabled* (or operate fine).
return !!this.state.masterPushRule?.enabled;
}
public componentDidMount(): void {
// noinspection JSIgnoredPromiseFromCall
this.refreshFromServer();
this.refreshFromAccountData();
}
public componentWillUnmount(): void {
this.settingWatchers.forEach((watcher) => SettingsStore.unwatchSetting(watcher));
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
if (this.state.deviceNotificationsEnabled !== prevState.deviceNotificationsEnabled) {
this.persistLocalNotificationSettings(this.state.deviceNotificationsEnabled);
}
}
private async refreshFromServer(): Promise<void> {
try {
const newState = (
await Promise.all([this.refreshRules(), this.refreshPushers(), this.refreshThreepids()])
).reduce((p, c) => Object.assign(c, p), {});
this.setState<
keyof Pick<
IState,
"phase" | "vectorKeywordRuleInfo" | "vectorPushRules" | "pushers" | "threepids" | "masterPushRule"
>
>({
...newState,
phase: Phase.Ready,
});
} catch (e) {
logger.error("Error setting up notifications for settings: ", e);
this.setState({ phase: Phase.Error });
}
}
private async refreshFromAccountData(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const settingsEvent = cli.getAccountData(getLocalNotificationAccountDataEventType(cli.deviceId));
if (settingsEvent) {
const notificationsEnabled = !(settingsEvent.getContent() as LocalNotificationSettings).is_silenced;
await this.updateDeviceNotifications(notificationsEnabled);
}
}
private persistLocalNotificationSettings(enabled: boolean): Promise<{}> {
const cli = MatrixClientPeg.safeGet();
return cli.setAccountData(getLocalNotificationAccountDataEventType(cli.deviceId), {
is_silenced: !enabled,
});
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.safeGet().getPushRules()!;
const categories: Record<string, RuleClass> = {
[RuleId.Master]: RuleClass.Master,
[RuleId.DM]: RuleClass.VectorGlobal,
[RuleId.EncryptedDM]: RuleClass.VectorGlobal,
[RuleId.Message]: RuleClass.VectorGlobal,
[RuleId.EncryptedMessage]: RuleClass.VectorGlobal,
[RuleId.ContainsDisplayName]: RuleClass.VectorMentions,
[RuleId.ContainsUserName]: RuleClass.VectorMentions,
[RuleId.AtRoomNotification]: RuleClass.VectorMentions,
[RuleId.InviteToSelf]: RuleClass.VectorOther,
[RuleId.IncomingCall]: RuleClass.VectorOther,
[RuleId.SuppressNotices]: RuleClass.VectorOther,
[RuleId.Tombstone]: RuleClass.VectorOther,
// Everything maps to a generic "other" (unknown rule)
};
const defaultRules: {
[k in RuleClass]: IAnnotatedPushRule[];
} = {
[RuleClass.Master]: [],
[RuleClass.VectorGlobal]: [],
[RuleClass.VectorMentions]: [],
[RuleClass.VectorOther]: [],
[RuleClass.Other]: [],
};
for (const k in ruleSets.global) {
// noinspection JSUnfilteredForInLoop
const kind = k as PushRuleKind;
for (const r of ruleSets.global[kind]!) {
const rule: IAnnotatedPushRule = Object.assign(r, { kind });
const category = categories[rule.rule_id] ?? RuleClass.Other;
if (rule.rule_id[0] === ".") {
defaultRules[category].push(rule);
}
}
}
const preparedNewState: Partial<IState> = {};
if (defaultRules.master.length > 0) {
preparedNewState.masterPushRule = defaultRules.master[0];
} else {
// XXX: Can this even happen? How do we safely recover?
throw new Error("Failed to locate a master push rule");
}
// Parse keyword rules
preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets);
// Prepare rendering for all of our known rules
preparedNewState.vectorPushRules = {};
const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther];
for (const category of vectorCategories) {
preparedNewState.vectorPushRules[category] = [];
for (const rule of defaultRules[category]) {
const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.rule_id];
const vectorState = definition.ruleToVectorState(rule)!;
preparedNewState.vectorPushRules[category]!.push({
ruleId: rule.rule_id,
rule,
vectorState,
syncedVectorState: maximumVectorState(defaultRules, rule, definition),
description: _t(definition.description),
});
}
// Quickly sort the rules for display purposes
preparedNewState.vectorPushRules[category]!.sort((a, b) => {
let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId);
let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId);
// Assume unknown things go at the end
if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length;
if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length;
return idxA - idxB;
});
if (category === KEYWORD_RULE_CATEGORY) {
preparedNewState.vectorPushRules[category]!.push({
ruleId: KEYWORD_RULE_ID,
description: _t("settings|notifications|messages_containing_keywords"),
vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
});
}
}
return preparedNewState;
}
private refreshPushers(): Promise<Partial<IState>> {
return MatrixClientPeg.safeGet().getPushers();
}
private refreshThreepids(): Promise<Partial<IState>> {
return MatrixClientPeg.safeGet().getThreePids();
}
private showSaveError(): void {
Modal.createDialog(ErrorDialog, {
title: _t("settings|notifications|error_saving"),
description: _t("settings|notifications|error_saving_detail"),
});
}
private onMasterRuleChanged = async (checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
const masterRule = this.state.masterPushRule!;
try {
await MatrixClientPeg.safeGet().setPushRuleEnabled("global", masterRule.kind, masterRule.rule_id, !checked);
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
logger.error("Error updating master push rule:", e);
this.showSaveError();
}
};
private setSavingError = (ruleId: RuleId | string): void => {
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.SavingError,
ruleIdsWithError: { ...ruleIdsWithError, [ruleId]: true },
}));
};
private updateDeviceNotifications = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("deviceNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onEmailNotificationsChanged = async (email: string, checked: boolean): Promise<void> => {
this.setState({ phase: Phase.Persisting });
try {
if (checked) {
await MatrixClientPeg.safeGet().setPusher({
kind: "email",
app_id: "m.email",
pushkey: email,
app_display_name: "Email Notifications",
device_display_name: email,
lang: navigator.language,
data: {
brand: SdkConfig.get().brand,
},
// We always append for email pushers since we don't want to stop other
// accounts notifying to the same email address
append: true,
});
} else {
const pusher = this.state.pushers?.find((p) => p.kind === "email" && p.pushkey === email);
if (pusher) {
await MatrixClientPeg.safeGet().removePusher(pusher.pushkey, pusher.app_id);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
logger.error("Error updating email pusher:", e);
this.showSaveError();
}
};
private onDesktopNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onDesktopShowBodyChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
};
private onAudioNotificationsChanged = async (checked: boolean): Promise<void> => {
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState): Promise<void> => {
this.setState(({ ruleIdsWithError }) => ({
phase: Phase.Persisting,
ruleIdsWithError: { ...ruleIdsWithError, [rule.ruleId]: false },
}));
try {
const cli = MatrixClientPeg.safeGet();
if (rule.ruleId === KEYWORD_RULE_ID) {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
// Update all the keywords
for (const rule of this.state.vectorKeywordRuleInfo.rules) {
let enabled: boolean | undefined;
let actions: PushRuleAction[] | undefined;
if (checkedState === VectorState.On) {
if (rule.actions.length !== 1) {
// XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else if (checkedState === VectorState.Loud) {
if (rule.actions.length !== 3) {
// XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else {
enabled = false;
}
if (actions) {
await cli.setPushRuleActions("global", rule.kind, rule.rule_id, actions);
}
if (enabled !== undefined) {
await cli.setPushRuleEnabled("global", rule.kind, rule.rule_id, enabled);
}
}
} else {
const definition: VectorPushRuleDefinition = VectorPushRulesDefinitions[rule.ruleId];
const actions = definition.vectorStateToActions[checkedState];
// we should not encounter this
// satisfies types
if (!rule.rule) {
throw new Error("Cannot update rule: push rule data is incomplete.");
}
await updatePushRuleActions(cli, rule.rule.rule_id, rule.rule.kind, actions);
await updateExistingPushRulesWithActions(cli, definition.syncedRuleIds, actions);
}
await this.refreshFromServer();
} catch (e) {
this.setSavingError(rule.ruleId);
logger.error("Error updating push rule:", e);
}
};
private onClearNotificationsClicked = async (): Promise<void> => {
try {
this.setState({ clearingNotifications: true });
const client = MatrixClientPeg.safeGet();
await clearAllNotifications(client);
} finally {
this.setState({ clearingNotifications: false });
}
};
private async setKeywords(
unsafeKeywords: (string | undefined)[],
originalRules: IAnnotatedPushRule[],
): Promise<void> {
try {
// De-duplicate and remove empties
const keywords = filterBoolean<string>(Array.from(new Set(unsafeKeywords)));
const oldKeywords = filterBoolean<string>(Array.from(new Set(originalRules.map((r) => r.pattern))));
// Note: Technically because of the UI interaction (at the time of writing), the diff
// will only ever be +/-1 so we don't really have to worry about efficiently handling
// tons of keyword changes.
const diff = arrayDiff<string>(oldKeywords, keywords);
for (const word of diff.removed) {
for (const rule of originalRules.filter((r) => r.pattern === word)) {
await MatrixClientPeg.safeGet().deletePushRule("global", rule.kind, rule.rule_id);
}
}
let ruleVectorState = this.state.vectorKeywordRuleInfo!.vectorState;
if (ruleVectorState === VectorState.Off) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of existing rules to apply the same actions
// when creating the new rule.
const existingRuleVectorState = originalRules.length
? PushRuleVectorState.contentRuleVectorStateKind(originalRules[0])
: undefined;
// set to same state as existing rule, or default to On
ruleVectorState = existingRuleVectorState ?? VectorState.On; //default
}
const kind = PushRuleKind.ContentSpecific;
for (const word of diff.added) {
await MatrixClientPeg.safeGet().addPushRule("global", kind, word, {
actions: PushRuleVectorState.actionsFor(ruleVectorState),
pattern: word,
});
if (ruleVectorState === VectorState.Off) {
await MatrixClientPeg.safeGet().setPushRuleEnabled("global", kind, word, false);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
logger.error("Error updating keyword push rules:", e);
this.showSaveError();
}
}
private onKeywordAdd = (keyword: string): void => {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We add the keyword immediately as a sort of local echo effect
this.setState(
{
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: [
...this.state.vectorKeywordRuleInfo.rules,
// XXX: Horrible assumption that we don't need the remaining fields
{ pattern: keyword } as IAnnotatedPushRule,
],
},
},
async (): Promise<void> => {
await this.setKeywords(
this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern),
originalRules,
);
},
);
};
private onKeywordRemove = (keyword: string): void => {
// should not encounter this
if (!this.state.vectorKeywordRuleInfo) {
throw new Error("Notification data is incomplete.");
}
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We remove the keyword immediately as a sort of local echo effect
this.setState(
{
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: this.state.vectorKeywordRuleInfo.rules.filter((r) => r.pattern !== keyword),
},
},
async (): Promise<void> => {
await this.setKeywords(
this.state.vectorKeywordRuleInfo!.rules.map((r) => r.pattern),
originalRules,
);
},
);
};
private renderTopSection(): JSX.Element {
const masterSwitch = (
<LabelledToggleSwitch
data-testid="notif-master-switch"
value={!this.isInhibited}
label={_t("settings|notifications|enable_notifications_account")}
caption={_t("settings|notifications|enable_notifications_account_detail")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>
);
// If all the rules are inhibited, don't show anything.
if (this.isInhibited) {
return masterSwitch;
}
const emailSwitches = (this.state.threepids || [])
.filter((t) => t.medium === ThreepidMedium.Email)
.map((e) => (
<LabelledToggleSwitch
data-testid="notif-email-switch"
key={e.address}
value={!!this.state.pushers?.some((p) => p.kind === "email" && p.pushkey === e.address)}
label={_t("settings|notifications|enable_email_notifications", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting}
/>
));
return (
<SettingsSubsection>
{masterSwitch}
<LabelledToggleSwitch
data-testid="notif-device-switch"
value={this.state.deviceNotificationsEnabled}
label={_t("settings|notifications|enable_notifications_device")}
onChange={(checked) => this.updateDeviceNotifications(checked)}
disabled={this.state.phase === Phase.Persisting}
/>
{this.state.deviceNotificationsEnabled && (
<>
<LabelledToggleSwitch
data-testid="notif-setting-notificationsEnabled"
value={this.state.desktopNotifications}
onChange={this.onDesktopNotificationsChanged}
label={_t("settings|notifications|enable_desktop_notifications_session")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-notificationBodyEnabled"
value={this.state.desktopShowBody}
onChange={this.onDesktopShowBodyChanged}
label={_t("settings|notifications|show_message_desktop_notification")}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
data-testid="notif-setting-audioNotificationsEnabled"
value={this.state.audioNotifications}
onChange={this.onAudioNotificationsChanged}
label={_t("settings|notifications|enable_audible_notifications_session")}
disabled={this.state.phase === Phase.Persisting}
/>
</>
)}
{emailSwitches}
</SettingsSubsection>
);
}
private renderCategory(category: RuleClass): ReactNode {
if (this.isInhibited) {
return null; // nothing to show for the section
}
let keywordComposer: JSX.Element | undefined;
if (category === RuleClass.VectorMentions) {
const tags = filterBoolean<string>(this.state.vectorKeywordRuleInfo?.rules.map((r) => r.pattern) || []);
keywordComposer = (
<TagComposer
tags={tags}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
disabled={this.state.phase === Phase.Persisting}
label={_t("notifications|keyword")}
placeholder={_t("notifications|keyword_new")}
/>
);
}
const VectorStateToLabel = {
[VectorState.On]: _t("common|on"),
[VectorState.Off]: _t("common|off"),
[VectorState.Loud]: _t("settings|notifications|noisy"),
};
const makeRadio = (r: IVectorPushRule, s: VectorState): JSX.Element => (
<StyledRadioButton
key={r.ruleId + s}
name={r.ruleId}
checked={(r.syncedVectorState ?? r.vectorState) === s}
onChange={this.onRadioChecked.bind(this, r, s)}
disabled={this.state.phase === Phase.Persisting}
aria-label={VectorStateToLabel[s]}
/>
);
const fieldsetRows = this.state.vectorPushRules?.[category]?.map((r) => (
<fieldset
key={category + r.ruleId}
data-testid={category + r.ruleId}
className="mx_UserNotifSettings_gridRowContainer"
>
<legend className="mx_UserNotifSettings_gridRowLabel">{r.description}</legend>
{makeRadio(r, VectorState.Off)}
{makeRadio(r, VectorState.On)}
{makeRadio(r, VectorState.Loud)}
{this.state.ruleIdsWithError[r.ruleId] && (
<div className="mx_UserNotifSettings_gridRowError">
<Caption isError>{_t("settings|notifications|error_updating")}</Caption>
</div>
)}
</fieldset>
));
let sectionName: string;
switch (category) {
case RuleClass.VectorGlobal:
sectionName = _t("notifications|class_global");
break;
case RuleClass.VectorMentions:
sectionName = _t("notifications|mentions_keywords");
break;
case RuleClass.VectorOther:
sectionName = _t("notifications|class_other");
break;
default:
throw new Error("Developer error: Unnamed notifications section: " + category);
}
return (
<div>
<div data-testid={`notif-section-${category}`} className="mx_UserNotifSettings_grid">
<SettingsSubsectionHeading heading={sectionName} />
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Off]}</span>
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.On]}</span>
<span className="mx_UserNotifSettings_gridColumnLabel">{VectorStateToLabel[VectorState.Loud]}</span>
{fieldsetRows}
</div>
{keywordComposer}
</div>
);
}
private renderTargets(): ReactNode {
if (this.isInhibited) return null; // no targets if there's no notifications
const rows = this.state.pushers?.map((p) => (
<tr key={p.kind + p.pushkey}>
<td>{p.app_display_name}</td>
<td>{p.device_display_name}</td>
</tr>
));
if (!rows?.length) return null; // no targets to show
return (
<div className="mx_UserNotifSettings_floatingSection">
<div>{_t("settings|notifications|push_targets")}</div>
<table>
<tbody>{rows}</tbody>
</table>
</div>
);
}
public render(): React.ReactNode {
if (this.state.phase === Phase.Loading) {
// Ends up default centered
return <Spinner />;
} else if (this.state.phase === Phase.Error) {
return <p data-testid="error-message">{_t("settings|notifications|error_loading")}</p>;
}
let clearNotifsButton: JSX.Element | undefined;
if (
MatrixClientPeg.safeGet()
.getRooms()
.some((r) => doesRoomHaveUnreadMessages(r, true))
) {
clearNotifsButton = (
<AccessibleButton
onClick={this.onClearNotificationsClicked}
disabled={this.state.clearingNotifications}
kind="danger"
className="mx_UserNotifSettings_clearNotifsButton"
data-testid="clear-notifications"
>
{_t("notifications|mark_all_read")}
</AccessibleButton>
);
}
return (
<>
{this.renderTopSection()}
{this.renderCategory(RuleClass.VectorGlobal)}
{this.renderCategory(RuleClass.VectorMentions)}
{this.renderCategory(RuleClass.VectorOther)}
{this.renderTargets()}
<NotificationActivitySettings />
{clearNotifsButton}
</>
);
}
}

View file

@ -0,0 +1,140 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* 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, { useState, JSX, PropsWithChildren } from "react";
import { Button } from "@vector-im/compound-web";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import PowerSelector from "../elements/PowerSelector";
import { _t } from "../../../languageHandler";
import SettingsFieldset from "./SettingsFieldset";
/**
* Display in a fieldset, the power level of the users and allow to change them.
* The apply button is disabled until the power level of an user is changed.
* If there is no user to display, the children is displayed instead.
*/
interface PowerLevelSelectorProps {
/**
* The power levels of the users
* The key is the user id and the value is the power level
*/
userLevels: Record<string, number>;
/**
* Whether the user can change the power levels of other users
*/
canChangeLevels: boolean;
/**
* The current user power level
*/
currentUserLevel: number;
/**
* The callback when the apply button is clicked
* @param value - new power level for the user
* @param userId - the user id
*/
onClick: (value: number, userId: string) => void;
/**
* Filter the users to display
* @param user
*/
filter: (user: string) => boolean;
/**
* The title of the fieldset
*/
title: string;
}
export function PowerLevelSelector({
userLevels,
canChangeLevels,
currentUserLevel,
onClick,
filter,
title,
children,
}: PropsWithChildren<PowerLevelSelectorProps>): JSX.Element | null {
const matrixClient = useMatrixClientContext();
const [currentPowerLevel, setCurrentPowerLevel] = useState<{ value: number; userId: string } | null>(null);
// If the power level has changed, we need to enable the apply button
const powerLevelChanged = Boolean(
currentPowerLevel && currentPowerLevel.value !== userLevels[currentPowerLevel?.userId],
);
const collator = new Intl.Collator();
// We sort the users by power level, then we filter them
const users = Object.keys(userLevels)
.sort((userA, userB) => sortUser(collator, userA, userB, userLevels))
.filter(filter);
// No user to display, we return the children into fragment to convert it to JSX.Element type
if (!users.length) return <>{children}</>;
return (
<SettingsFieldset legend={title}>
{users.map((userId) => {
// We only want to display users with a valid power level aka an integer
if (!Number.isInteger(userLevels[userId])) return;
const isMe = userId === matrixClient.getUserId();
// If I can change levels, I can change the level of anyone with a lower level than mine
const canChange = canChangeLevels && (userLevels[userId] < currentUserLevel || isMe);
// When the new power level is selected, the fields are rerendered and we need to keep the current value
const userLevel = currentPowerLevel?.userId === userId ? currentPowerLevel?.value : userLevels[userId];
return (
<PowerSelector
value={userLevel}
disabled={!canChange}
label={userId}
key={userId}
onChange={(value) => setCurrentPowerLevel({ value, userId })}
/>
);
})}
<Button
size="sm"
kind="primary"
// mx_Dialog_nonDialogButton is necessary to avoid the Dialog CSS to override the button style
className="mx_Dialog_nonDialogButton mx_PowerLevelSelector_Button"
onClick={() => {
if (currentPowerLevel !== null) {
onClick(currentPowerLevel.value, currentPowerLevel.userId);
setCurrentPowerLevel(null);
}
}}
disabled={!powerLevelChanged}
aria-label={_t("action|apply")}
>
{_t("action|apply")}
</Button>
</SettingsFieldset>
);
}
/**
* Sort the users by power level, then by name
* @param userA
* @param userB
* @param userLevels
*/
function sortUser(
collator: Intl.Collator,
userA: string,
userB: string,
userLevels: PowerLevelSelectorProps["userLevels"],
): number {
const powerLevelDiff = userLevels[userA] - userLevels[userB];
return powerLevelDiff !== 0
? powerLevelDiff
: collator.compare(userA.toLocaleLowerCase(), userB.toLocaleLowerCase());
}

View file

@ -0,0 +1,426 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { logger } from "matrix-js-sdk/src/logger";
import { BackupTrustInfo, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
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<{}, IState> {
private unmounted = false;
public constructor(props: {}) {
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.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.getKeyBackupVersion();
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.createDialogAsync(
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise<
typeof 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!;
MatrixClientPeg.safeGet()
.getCrypto()
?.deleteKeyBackupVersion(versionToDelete)
.then(() => {
this.loadBackupStatus();
});
},
});
};
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}
</>
);
}
}

View file

@ -0,0 +1,422 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import { IThreepid } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { getThreepidsWithBindStatus } from "../../../boundThreepids";
import IdentityAuthClient from "../../../IdentityAuthClient";
import { abbreviateUrl, parseUrl, unabbreviateUrl } from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from "../../../utils/IdentityServerUtils";
import { timeout } from "../../../utils/promise";
import { ActionPayload } from "../../../dispatcher/payloads";
import InlineSpinner from "../elements/InlineSpinner";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import QuestionDialog from "../dialogs/QuestionDialog";
import SettingsFieldset from "./SettingsFieldset";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
// We'll wait up to this long when checking for 3PID bindings on the IS.
const REACHABILITY_TIMEOUT = 10000; // ms
/**
* Check an IS URL is valid, including liveness check
*
* @param {string} u The url to check
* @returns {string} null if url passes all checks, otherwise i18ned error string
*/
async function checkIdentityServerUrl(u: string): Promise<string | null> {
const parsedUrl = parseUrl(u);
if (parsedUrl.protocol !== "https:") return _t("identity_server|url_not_https");
// XXX: duplicated logic from js-sdk but it's quite tied up in the validation logic in the
// js-sdk so probably as easy to duplicate it than to separate it out so we can reuse it
try {
const response = await fetch(u + "/_matrix/identity/v2");
if (response.ok) {
return null;
} else if (response.status < 200 || response.status >= 300) {
return _t("identity_server|error_invalid", { code: response.status });
} else {
return _t("identity_server|error_connection");
}
} catch (e) {
return _t("identity_server|error_connection");
}
}
interface IProps {
// Whether or not the identity server is missing terms. This affects the text
// shown to the user.
missingTerms: boolean;
}
interface IState {
defaultIdServer?: string;
currentClientIdServer?: string;
idServer: string;
error?: string;
busy: boolean;
disconnectBusy: boolean;
checking: boolean;
}
export default class SetIdServer extends React.Component<IProps, IState> {
private dispatcherRef?: string;
public constructor(props: IProps) {
super(props);
let defaultIdServer = "";
if (!MatrixClientPeg.safeGet().getIdentityServerUrl() && getDefaultIdentityServerUrl()) {
// If no identity server is configured but there's one in the config, prepopulate
// the field to help the user.
defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl());
}
this.state = {
defaultIdServer,
currentClientIdServer: MatrixClientPeg.safeGet().getIdentityServerUrl(),
idServer: "",
busy: false,
disconnectBusy: false,
checking: false,
};
}
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
}
public componentWillUnmount(): void {
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
}
private onAction = (payload: ActionPayload): void => {
// We react to changes in the identity server in the event the user is staring at this form
// when changing their identity server on another device.
if (payload.action !== "id_server_changed") return;
this.setState({
currentClientIdServer: MatrixClientPeg.safeGet().getIdentityServerUrl(),
});
};
private onIdentityServerChanged = (ev: React.ChangeEvent<HTMLInputElement>): void => {
const u = ev.target.value;
this.setState({ idServer: u });
};
private getTooltip = (): ReactNode => {
if (this.state.checking) {
return (
<div>
<InlineSpinner />
{_t("identity_server|checking")}
</div>
);
} else if (this.state.error) {
return <strong className="warning">{this.state.error}</strong>;
} else {
return null;
}
};
private idServerChangeEnabled = (): boolean => {
return !!this.state.idServer && !this.state.busy;
};
private saveIdServer = (fullUrl: string): void => {
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.safeGet().setAccountData("m.identity_server", {
base_url: fullUrl,
});
this.setState({
busy: false,
error: undefined,
currentClientIdServer: fullUrl,
idServer: "",
});
};
private checkIdServer = async (e: React.SyntheticEvent): Promise<void> => {
e.preventDefault();
const { idServer, currentClientIdServer } = this.state;
this.setState({ busy: true, checking: true, error: undefined });
const fullUrl = unabbreviateUrl(idServer);
let errStr = await checkIdentityServerUrl(fullUrl);
if (!errStr) {
try {
this.setState({ checking: false }); // clear tooltip
// Test the identity server by trying to register with it. This
// may result in a terms of service prompt.
const authClient = new IdentityAuthClient(fullUrl);
await authClient.getAccessToken();
let save = true;
// Double check that the identity server even has terms of service.
const hasTerms = await doesIdentityServerHaveTerms(MatrixClientPeg.safeGet(), fullUrl);
if (!hasTerms) {
const [confirmed] = await this.showNoTermsWarning(fullUrl);
save = !!confirmed;
}
// Show a general warning, possibly with details about any bound
// 3PIDs that would be left behind.
if (save && currentClientIdServer && fullUrl !== currentClientIdServer) {
const [confirmed] = await this.showServerChangeWarning({
title: _t("identity_server|change"),
unboundMessage: _t(
"identity_server|change_prompt",
{},
{
current: (sub) => <strong>{abbreviateUrl(currentClientIdServer)}</strong>,
new: (sub) => <strong>{abbreviateUrl(idServer)}</strong>,
},
),
button: _t("action|continue"),
});
save = !!confirmed;
}
if (save) {
this.saveIdServer(fullUrl);
}
} catch (e) {
logger.error(e);
errStr = _t("identity_server|error_invalid_or_terms");
}
}
this.setState({
busy: false,
checking: false,
error: errStr ?? undefined,
currentClientIdServer: MatrixClientPeg.safeGet().getIdentityServerUrl(),
});
};
private showNoTermsWarning(fullUrl: string): Promise<[ok?: boolean]> {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("terms|identity_server_no_terms_title"),
description: (
<div>
<strong className="warning">{_t("identity_server|no_terms")}</strong>
<span>&nbsp;{_t("terms|identity_server_no_terms_description_2")}</span>
</div>
),
button: _t("action|continue"),
});
return finished;
}
private onDisconnectClicked = async (): Promise<void> => {
this.setState({ disconnectBusy: true });
try {
const [confirmed] = await this.showServerChangeWarning({
title: _t("identity_server|disconnect"),
unboundMessage: _t(
"identity_server|disconnect_server",
{},
{ idserver: (sub) => <strong>{abbreviateUrl(this.state.currentClientIdServer)}</strong> },
),
button: _t("action|disconnect"),
});
if (confirmed) {
this.disconnectIdServer();
}
} finally {
this.setState({ disconnectBusy: false });
}
};
private async showServerChangeWarning({
title,
unboundMessage,
button,
}: {
title: string;
unboundMessage: ReactNode;
button: string;
}): Promise<[ok?: boolean]> {
const { currentClientIdServer } = this.state;
let threepids: IThreepid[] = [];
let currentServerReachable = true;
try {
threepids = await timeout(
getThreepidsWithBindStatus(MatrixClientPeg.safeGet()),
Promise.reject(new Error("Timeout attempting to reach identity server")),
REACHABILITY_TIMEOUT,
);
} catch (e) {
currentServerReachable = false;
logger.warn(
`Unable to reach identity server at ${currentClientIdServer} to check ` +
`for 3PIDs during IS change flow`,
);
logger.warn(e);
}
const boundThreepids = threepids.filter((tp) => tp.bound);
let message;
let danger = false;
const messageElements = {
idserver: (sub: string) => <strong>{abbreviateUrl(currentClientIdServer)}</strong>,
b: (sub: string) => <strong>{sub}</strong>,
};
if (!currentServerReachable) {
message = (
<div>
<p>{_t("identity_server|disconnect_offline_warning", {}, messageElements)}</p>
<p>{_t("identity_server|suggestions")}</p>
<ul>
<li>{_t("identity_server|suggestions_1")}</li>
<li>
{_t(
"identity_server|suggestions_2",
{},
{
idserver: messageElements.idserver,
},
)}
</li>
<li>{_t("identity_server|suggestions_3")}</li>
</ul>
</div>
);
danger = true;
button = _t("identity_server|disconnect_anyway");
} else if (boundThreepids.length) {
message = (
<div>
<p>{_t("identity_server|disconnect_personal_data_warning_1", {}, messageElements)}</p>
<p>{_t("identity_server|disconnect_personal_data_warning_2")}</p>
</div>
);
danger = true;
button = _t("identity_server|disconnect_anyway");
} else {
message = unboundMessage;
}
const { finished } = Modal.createDialog(QuestionDialog, {
title,
description: message,
button,
cancelButton: _t("action|go_back"),
danger,
});
return finished;
}
private disconnectIdServer = (): void => {
// Account data change will update localstorage, client, etc through dispatcher
MatrixClientPeg.safeGet().setAccountData("m.identity_server", {
base_url: null, // clear
});
let newFieldVal = "";
if (getDefaultIdentityServerUrl()) {
// Prepopulate the client's default so the user at least has some idea of
// a valid value they might enter
newFieldVal = abbreviateUrl(getDefaultIdentityServerUrl());
}
this.setState({
busy: false,
error: undefined,
currentClientIdServer: MatrixClientPeg.safeGet().getIdentityServerUrl(),
idServer: newFieldVal,
});
};
public render(): React.ReactNode {
const idServerUrl = this.state.currentClientIdServer;
let sectionTitle;
let bodyText;
if (idServerUrl) {
sectionTitle = _t("identity_server|url", { server: abbreviateUrl(idServerUrl) });
bodyText = _t(
"identity_server|description_connected",
{},
{ server: (sub) => <strong>{abbreviateUrl(idServerUrl)}</strong> },
);
if (this.props.missingTerms) {
bodyText = _t(
"identity_server|change_server_prompt",
{},
{ server: (sub) => <strong>{abbreviateUrl(idServerUrl)}</strong> },
);
}
} else {
sectionTitle = _t("common|identity_server");
bodyText = _t("identity_server|description_disconnected");
}
let discoSection;
if (idServerUrl) {
let discoButtonContent: React.ReactNode = _t("action|disconnect");
let discoBodyText = _t("identity_server|disconnect_warning");
if (this.props.missingTerms) {
discoBodyText = _t("identity_server|description_optional");
discoButtonContent = _t("identity_server|do_not_use");
}
if (this.state.disconnectBusy) {
discoButtonContent = <InlineSpinner />;
}
discoSection = (
<>
<SettingsSubsectionText>{discoBodyText}</SettingsSubsectionText>
<AccessibleButton onClick={this.onDisconnectClicked} kind="danger_sm">
{discoButtonContent}
</AccessibleButton>
</>
);
}
return (
<SettingsFieldset legend={sectionTitle} description={bodyText}>
<form className="mx_SetIdServer" onSubmit={this.checkIdServer}>
<Field
label={_t("identity_server|url_field_label")}
type="text"
autoComplete="off"
placeholder={this.state.defaultIdServer}
value={this.state.idServer}
onChange={this.onIdentityServerChanged}
tooltipContent={this.getTooltip()}
tooltipClassName="mx_SetIdServer_tooltip"
disabled={this.state.busy}
forceValidity={this.state.error ? false : undefined}
/>
<AccessibleButton
type="submit"
kind="primary_sm"
onClick={this.checkIdServer}
disabled={!this.idServerChangeEnabled()}
>
{_t("action|change")}
</AccessibleButton>
{discoSection}
</form>
</SettingsFieldset>
);
}
}

View file

@ -0,0 +1,92 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import { IntegrationManagerInstance } from "../../../integrations/IntegrationManagerInstance";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import ToggleSwitch from "../elements/ToggleSwitch";
import Heading from "../typography/Heading";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
import { UIFeature } from "../../../settings/UIFeature";
interface IProps {}
interface IState {
currentManager: IntegrationManagerInstance | null;
provisioningEnabled: boolean;
}
export default class SetIntegrationManager extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const currentManager = IntegrationManagers.sharedInstance().getPrimaryManager();
this.state = {
currentManager,
provisioningEnabled: SettingsStore.getValue("integrationProvisioning"),
};
}
private onProvisioningToggled = (): void => {
const current = this.state.provisioningEnabled;
SettingsStore.setValue("integrationProvisioning", null, SettingLevel.ACCOUNT, !current).catch((err) => {
logger.error("Error changing integration manager provisioning");
logger.error(err);
this.setState({ provisioningEnabled: current });
});
this.setState({ provisioningEnabled: !current });
};
public render(): React.ReactNode {
const currentManager = this.state.currentManager;
let managerName;
let bodyText;
if (currentManager) {
managerName = `(${currentManager.name})`;
bodyText = _t(
"integration_manager|use_im_default",
{ serverName: currentManager.name },
{ b: (sub) => <strong>{sub}</strong> },
);
} else {
bodyText = _t("integration_manager|use_im");
}
if (!SettingsStore.getValue(UIFeature.Widgets)) return null;
return (
<label
className="mx_SetIntegrationManager"
data-testid="mx_SetIntegrationManager"
htmlFor="toggle_integration"
>
<div className="mx_SettingsFlag">
<div className="mx_SetIntegrationManager_heading_manager">
<Heading size="3">{_t("integration_manager|manage_title")}</Heading>
<Heading size="4">{managerName}</Heading>
</div>
<ToggleSwitch
id="toggle_integration"
checked={this.state.provisioningEnabled}
disabled={false}
onChange={this.onProvisioningToggled}
/>
</div>
<SettingsSubsectionText>{bodyText}</SettingsSubsectionText>
<SettingsSubsectionText>{_t("integration_manager|explainer")}</SettingsSubsectionText>
</label>
);
}
}

View file

@ -0,0 +1,32 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, HTMLAttributes } from "react";
import classNames from "classnames";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface Props extends HTMLAttributes<HTMLFieldSetElement> {
// section title
legend: string | ReactNode;
description?: string | ReactNode;
}
const SettingsFieldset: React.FC<Props> = ({ legend, className, children, description, ...rest }) => (
<fieldset {...rest} className={classNames("mx_SettingsFieldset", className)}>
<legend className="mx_SettingsFieldset_legend">{legend}</legend>
{description && (
<div className="mx_SettingsFieldset_description">
<SettingsSubsectionText>{description}</SettingsSubsectionText>
</div>
)}
<div className="mx_SettingsFieldset_content">{children}</div>
</fieldset>
);
export default SettingsFieldset;

View file

@ -0,0 +1,113 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 SpellCheckLanguagesDropdown from "../../../components/views/elements/SpellCheckLanguagesDropdown";
import AccessibleButton, { ButtonEvent } from "../../../components/views/elements/AccessibleButton";
import { _t, getUserLanguage } from "../../../languageHandler";
interface ExistingSpellCheckLanguageIProps {
language: string;
/**
* The label to render on the component. If not provided, the language code will be used.
*/
label?: string;
onRemoved(language: string): void;
}
interface SpellCheckLanguagesIProps {
languages: Array<string>;
onLanguagesChange(languages: Array<string>): void;
}
interface SpellCheckLanguagesIState {
newLanguage: string;
}
export class ExistingSpellCheckLanguage extends React.Component<ExistingSpellCheckLanguageIProps> {
private onRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
return this.props.onRemoved(this.props.language);
};
public render(): React.ReactNode {
return (
<div className="mx_ExistingSpellCheckLanguage">
<span className="mx_ExistingSpellCheckLanguage_language">
{this.props.label ?? this.props.language}
</span>
<AccessibleButton onClick={this.onRemove} kind="danger_sm">
{_t("action|remove")}
</AccessibleButton>
</div>
);
}
}
export default class SpellCheckLanguages extends React.Component<SpellCheckLanguagesIProps, SpellCheckLanguagesIState> {
public constructor(props: SpellCheckLanguagesIProps) {
super(props);
this.state = {
newLanguage: "",
};
}
private onRemoved = (language: string): void => {
const languages = this.props.languages.filter((e) => e !== language);
this.props.onLanguagesChange(languages);
};
private onAddClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
const language = this.state.newLanguage;
this.setState({ newLanguage: "" });
if (!language) return;
if (this.props.languages.includes(language)) return;
this.props.languages.push(language);
this.props.onLanguagesChange(this.props.languages);
};
private onNewLanguageChange = (language: string): void => {
if (this.state.newLanguage === language) return;
this.setState({ newLanguage: language });
};
public render(): React.ReactNode {
const intl = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
const existingSpellCheckLanguages = this.props.languages.map((e) => {
return <ExistingSpellCheckLanguage language={e} label={intl.of(e)} onRemoved={this.onRemoved} key={e} />;
});
const addButton = (
<AccessibleButton onClick={this.onAddClick} kind="primary">
{_t("action|add")}
</AccessibleButton>
);
return (
<>
{existingSpellCheckLanguages}
<form onSubmit={this.onAddClick} noValidate={true}>
<SpellCheckLanguagesDropdown
className="mx_GeneralUserSettingsTab_spellCheckLanguageInput"
value={this.state.newLanguage}
onOptionChange={this.onNewLanguageChange}
/>
{addButton}
</form>
</>
);
}
}

View file

@ -0,0 +1,336 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* 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, JSX, useCallback, useMemo, useRef, useState } from "react";
import {
InlineField,
ToggleControl,
Label,
Root,
RadioControl,
EditInPlace,
IconButton,
ErrorMessage,
HelpMessage,
} from "@vector-im/compound-web";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import SettingsSubsection from "./shared/SettingsSubsection";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import dis from "../../../dispatcher/dispatcher";
import { RecheckThemePayload } from "../../../dispatcher/payloads/RecheckThemePayload";
import { Action } from "../../../dispatcher/actions";
import { useTheme } from "../../../hooks/useTheme";
import { findHighContrastTheme, getOrderedThemes, CustomTheme as CustomThemeType, ITheme } from "../../../theme";
import { useSettingValue } from "../../../hooks/useSettings";
/**
* Panel to choose the theme
*/
export function ThemeChoicePanel(): JSX.Element {
const themeState = useTheme();
const themeWatcher = useRef(new ThemeWatcher());
const customThemeEnabled = useSettingValue<boolean>("feature_custom_themes");
return (
<SettingsSubsection heading={_t("common|theme")} legacy={false} data-testid="themePanel">
{themeWatcher.current.isSystemThemeSupported() && (
<SystemTheme systemThemeActivated={themeState.systemThemeActivated} />
)}
<ThemeSelectors theme={themeState.theme} disabled={themeState.systemThemeActivated} />
{customThemeEnabled && <CustomTheme theme={themeState.theme} />}
</SettingsSubsection>
);
}
/**
* Component to toggle the system theme
*/
interface SystemThemeProps {
/* Whether the system theme is activated */
systemThemeActivated: boolean;
}
/**
* Component to toggle the system theme
*/
function SystemTheme({ systemThemeActivated }: SystemThemeProps): JSX.Element {
return (
<Root
onChange={async (evt) => {
const checked = new FormData(evt.currentTarget).get("systemTheme") === "on";
await SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
}}
>
<InlineField
name="systemTheme"
control={<ToggleControl name="systemTheme" defaultChecked={systemThemeActivated} />}
>
<Label>{SettingsStore.getDisplayName("use_system_theme")}</Label>
</InlineField>
</Root>
);
}
/**
* Component to select the theme
*/
interface ThemeSelectorProps {
/* The current theme */
theme: string;
/* The theme can't be selected */
disabled: boolean;
}
/**
* Component to select the theme
*/
function ThemeSelectors({ theme, disabled }: ThemeSelectorProps): JSX.Element {
const themes = useThemes();
return (
<Root
className="mx_ThemeChoicePanel_ThemeSelectors"
onChange={async (evt) => {
// We don't have any file in the form, we can cast it as string safely
const newTheme = new FormData(evt.currentTarget).get("themeSelector") as string | null;
// Do nothing if the same theme is selected
if (!newTheme || theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme).catch(() => {
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme });
});
// The settings watcher doesn't fire until the echo comes back from the
// server, so to make the theme change immediately we need to manually
// do the dispatch now
// XXX: The local echoed value appears to be unreliable, in particular
// when settings custom themes(!) so adding forceTheme to override
// the value from settings.
dis.dispatch<RecheckThemePayload>({ action: Action.RecheckTheme, forceTheme: newTheme });
}}
>
{themes.map((_theme) => {
const isChecked = theme === _theme.id;
return (
<InlineField
className={classNames("mx_ThemeChoicePanel_themeSelector", {
[`mx_ThemeChoicePanel_themeSelector_enabled`]: !disabled && theme === _theme.id,
[`mx_ThemeChoicePanel_themeSelector_disabled`]: disabled,
// We need to force the compound theme to be light or dark
// The theme selection doesn't depend on the current theme
// For example when the light theme is used, the dark theme selector should be dark
"cpd-theme-light": !_theme.isDark,
"cpd-theme-dark": _theme.isDark,
})}
name="themeSelector"
key={_theme.id}
control={
<RadioControl
name="themeSelector"
checked={!disabled && isChecked}
disabled={disabled}
value={_theme.id}
/>
}
>
<Label className="mx_ThemeChoicePanel_themeSelector_Label">{_theme.name}</Label>
</InlineField>
);
})}
</Root>
);
}
/**
* Return all the available themes
*/
function useThemes(): Array<ITheme & { isDark: boolean }> {
const customThemes = useSettingValue<CustomThemeType[] | undefined>("custom_themes");
return useMemo(() => {
// Put the custom theme into a map
// To easily find the theme by name when going through the themes list
const checkedCustomThemes = customThemes || [];
const customThemeMap = checkedCustomThemes.reduce(
(map, theme) => map.set(theme.name, theme),
new Map<string, CustomThemeType>(),
);
const themes = getOrderedThemes();
// Separate the built-in themes from the custom themes
// To insert the high contrast theme between them
const builtInThemes = themes.filter((theme) => !customThemeMap.has(theme.name));
const otherThemes = themes.filter((theme) => customThemeMap.has(theme.name));
const highContrastTheme = makeHighContrastTheme();
if (highContrastTheme) builtInThemes.push(highContrastTheme);
const allThemes = builtInThemes.concat(otherThemes);
// Check if the themes are dark
return allThemes.map((theme) => {
const customTheme = customThemeMap.get(theme.name);
const isDark = (customTheme ? customTheme.is_dark : theme.id.includes("dark")) || false;
return { ...theme, isDark };
});
}, [customThemes]);
}
/**
* Create the light high contrast theme
*/
function makeHighContrastTheme(): ITheme | undefined {
const lightHighContrastId = findHighContrastTheme("light");
if (lightHighContrastId) {
return {
name: _t("settings|appearance|high_contrast"),
id: lightHighContrastId,
};
}
}
interface CustomThemeProps {
/**
* The current theme
*/
theme: string;
}
/**
* Add and manager custom themes
*/
function CustomTheme({ theme }: CustomThemeProps): JSX.Element {
const [customTheme, setCustomTheme] = useState<string>("");
const [error, setError] = useState<string>();
const clear = useCallback(() => {
setError(undefined);
setCustomTheme("");
}, [setError, setCustomTheme]);
return (
<div className="mx_ThemeChoicePanel_CustomTheme">
<EditInPlace
className="mx_ThemeChoicePanel_CustomTheme_EditInPlace"
label={_t("settings|appearance|custom_theme_add")}
cancelButtonLabel={_t("action|cancel")}
saveButtonLabel={_t("settings|appearance|custom_theme_add")}
savingLabel={_t("settings|appearance|custom_theme_downloading")}
value={customTheme}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setError(undefined);
setCustomTheme(e.target.value);
}}
onSave={async () => {
// The field empty is empty
if (!customTheme) return;
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
try {
const r = await fetch(customTheme);
// XXX: need some schema for this
const themeInfo = await r.json();
if (
!themeInfo ||
typeof themeInfo["name"] !== "string" ||
typeof themeInfo["colors"] !== "object"
) {
setError(_t("settings|appearance|custom_theme_invalid"));
return;
}
// Check if the theme is already existing
const isAlreadyExisting = Boolean(currentThemes.find((t) => t.name === themeInfo.name));
if (isAlreadyExisting) {
clear();
return;
}
currentThemes.push(themeInfo);
} catch (e) {
logger.error(e);
setError(_t("settings|appearance|custom_theme_error_downloading"));
return;
}
// Reset the error
clear();
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
}}
onCancel={clear}
>
<HelpMessage>{_t("settings|appearance|custom_theme_help")}</HelpMessage>
{error && <ErrorMessage>{error}</ErrorMessage>}
</EditInPlace>
<CustomThemeList theme={theme} />
</div>
);
}
interface CustomThemeListProps {
/*
* The current theme
*/
theme: string;
}
/**
* List of the custom themes
*/
function CustomThemeList({ theme: currentTheme }: CustomThemeListProps): JSX.Element {
const customThemes = useSettingValue<CustomThemeType[]>("custom_themes") || [];
return (
<ul className="mx_ThemeChoicePanel_CustomThemeList">
{customThemes.map((theme) => {
return (
<li key={theme.name} className="mx_ThemeChoicePanel_CustomThemeList_theme" aria-label={theme.name}>
<span className="mx_ThemeChoicePanel_CustomThemeList_name">{theme.name}</span>
<IconButton
destructive={true}
aria-label={_t("action|delete")}
tooltip={_t("action|delete")}
onClick={async () => {
// Get the custom themes and do a cheap clone
// To avoid to mutate the original array in the settings
const currentThemes =
SettingsStore.getValue<CustomThemeType[]>("custom_themes").map((t) => t) || [];
// Remove the theme from the list
const newThemes = currentThemes.filter((t) => t.name !== theme.name);
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, newThemes);
// If the delete custom theme is the current theme, reset the theme to the default theme
// By settings the theme at null at the device level, we are getting the default theme
if (currentTheme === `custom-${theme.name}`) {
await SettingsStore.setValue("theme", null, SettingLevel.DEVICE, null);
dis.dispatch<RecheckThemePayload>({
action: Action.RecheckTheme,
});
}
}}
>
<DeleteIcon />
</IconButton>
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,88 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, useState } from "react";
import { UpdateCheckStatus } from "../../../BasePlatform";
import PlatformPeg from "../../../PlatformPeg";
import { useDispatcher } from "../../../hooks/useDispatcher";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { _t } from "../../../languageHandler";
import InlineSpinner from "../../../components/views/elements/InlineSpinner";
import AccessibleButton from "../../../components/views/elements/AccessibleButton";
import { CheckUpdatesPayload } from "../../../dispatcher/payloads/CheckUpdatesPayload";
function installUpdate(): void {
PlatformPeg.get()?.installUpdate();
}
function getStatusText(status: UpdateCheckStatus, errorDetail?: string): ReactNode {
switch (status) {
case UpdateCheckStatus.Error:
return _t("update|error_encountered", { errorDetail });
case UpdateCheckStatus.Checking:
return _t("update|checking");
case UpdateCheckStatus.NotAvailable:
return _t("update|no_update");
case UpdateCheckStatus.Downloading:
return _t("update|downloading");
case UpdateCheckStatus.Ready:
return _t(
"update|new_version_available",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={installUpdate}>
{sub}
</AccessibleButton>
),
},
);
}
}
const doneStatuses = [UpdateCheckStatus.Ready, UpdateCheckStatus.Error, UpdateCheckStatus.NotAvailable];
const UpdateCheckButton: React.FC = () => {
const [state, setState] = useState<CheckUpdatesPayload | null>(null);
const onCheckForUpdateClick = (): void => {
setState(null);
PlatformPeg.get()?.startUpdateCheck();
};
useDispatcher(dis, ({ action, ...params }) => {
if (action === Action.CheckUpdates) {
setState(params as CheckUpdatesPayload);
}
});
const busy = !!state && !doneStatuses.includes(state.status);
let suffix: JSX.Element | undefined;
if (state) {
suffix = (
<span className="mx_UpdateCheckButton_summary">
{getStatusText(state.status, state.detail)}
{busy && <InlineSpinner />}
</span>
);
}
return (
<React.Fragment>
<AccessibleButton onClick={onCheckForUpdateClick} kind="primary_outline" disabled={busy}>
{_t("update|check_action")}
</AccessibleButton>
{suffix}
</React.Fragment>
);
};
export default UpdateCheckButton;

View file

@ -0,0 +1,129 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useEffect, useState } from "react";
import { ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { Alert } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import InlineSpinner from "../elements/InlineSpinner";
import SettingsSubsection from "./shared/SettingsSubsection";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../AddThreepid";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { AddRemoveThreepids } from "./AddRemoveThreepids";
type LoadingState = "loading" | "loaded" | "error";
interface ThreepidSectionWrapperProps {
error: string;
loadingState: LoadingState;
children: React.ReactNode;
}
const ThreepidSectionWrapper: React.FC<ThreepidSectionWrapperProps> = ({ error, loadingState, children }) => {
if (loadingState === "loading") {
return <InlineSpinner />;
} else if (loadingState === "error") {
return (
<Alert type="critical" title={_t("common|error")}>
{error}
</Alert>
);
} else {
return <>{children}</>;
}
};
interface UserPersonalInfoSettingsProps {
canMake3pidChanges: boolean;
}
/**
* Settings controls allowing the user to set personal information like email addresses.
*/
export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> = ({ canMake3pidChanges }) => {
const [emails, setEmails] = useState<ThirdPartyIdentifier[] | undefined>();
const [phoneNumbers, setPhoneNumbers] = useState<ThirdPartyIdentifier[] | undefined>();
const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading");
const client = useMatrixClientContext();
const updateThreepids = useCallback(async () => {
try {
const threepids = await client.getThreePids();
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
}, [client]);
useEffect(() => {
updateThreepids().then();
}, [updateThreepids]);
const onEmailsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
const onMsisdnsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
return (
<div>
<h2>{_t("settings|general|personal_info")}</h2>
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
stretchContent
data-testid="mx_AccountEmailAddresses"
>
<ThreepidSectionWrapper
error={_t("settings|general|unable_to_load_emails")}
loadingState={loadingState}
>
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={emails!}
onChange={onEmailsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|general|msisdns_heading")}
stretchContent
data-testid="mx_AccountPhoneNumbers"
>
<ThreepidSectionWrapper
error={_t("settings|general|unable_to_load_msisdns")}
loadingState={loadingState}
>
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers!}
onChange={onMsisdnsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>
</div>
);
};
export default UserPersonalInfoSettings;

View file

@ -0,0 +1,248 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2024 The Matrix.org Foundation C.I.C.
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, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { EditInPlace, Alert, ErrorMessage } from "@vector-im/compound-web";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
import { _t } from "../../../languageHandler";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import AvatarSetting from "./AvatarSetting";
import PosthogTrackers from "../../../PosthogTrackers";
import { formatBytes } from "../../../utils/FormattingUtils";
import { useToastContext } from "../../../contexts/ToastContext";
import InlineSpinner from "../elements/InlineSpinner";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { useId } from "../../../utils/useId";
import CopyableText from "../elements/CopyableText";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import AccessibleButton from "../elements/AccessibleButton";
import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Flex } from "../../utils/Flex";
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
<>
<InlineSpinner />
{children}
</>
);
interface UsernameBoxProps {
username: string;
}
const UsernameBox: React.FC<UsernameBoxProps> = ({ username }) => {
const labelId = useId();
return (
<div className="mx_UserProfileSettings_profile_controls_userId">
<div className="mx_UserProfileSettings_profile_controls_userId_label" id={labelId}>
{_t("settings|general|username")}
</div>
<CopyableText getTextToCopy={() => username} aria-labelledby={labelId}>
{username}
</CopyableText>
</div>
);
};
interface ManageAccountButtonProps {
externalAccountManagementUrl: string;
}
const ManageAccountButton: React.FC<ManageAccountButtonProps> = ({ externalAccountManagementUrl }) => (
<AccessibleButton
onClick={null}
element="a"
kind="primary"
target="_blank"
rel="noreferrer noopener"
href={externalAccountManagementUrl}
data-testid="external-account-management-link"
>
<PopOutIcon className="mx_UserProfileSettings_accountmanageIcon" width="24" height="24" />
{_t("settings|general|oidc_manage_button")}
</AccessibleButton>
);
const SignOutButton: React.FC = () => {
const client = useMatrixClientContext();
const onClick = useCallback(async () => {
if (await shouldShowLogoutDialog(client)) {
Modal.createDialog(LogoutDialog);
} else {
defaultDispatcher.dispatch({ action: "logout" });
}
}, [client]);
return (
<AccessibleButton onClick={onClick} kind="danger_outline">
<SignOutIcon className="mx_UserProfileSettings_accountmanageIcon" width="24" height="24" />
{_t("action|sign_out")}
</AccessibleButton>
);
};
interface UserProfileSettingsProps {
// The URL to redirect the user to in order to manage their account.
externalAccountManagementUrl?: string;
// Whether the homeserver allows the user to set their display name.
canSetDisplayName: boolean;
// Whether the homeserver allows the user to set their avatar.
canSetAvatar: boolean;
}
/**
* A group of settings views to allow the user to set their profile information.
*/
const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
externalAccountManagementUrl,
canSetDisplayName,
canSetAvatar,
}) => {
const [avatarURL, setAvatarURL] = useState(OwnProfileStore.instance.avatarMxc);
const [displayName, setDisplayName] = useState(OwnProfileStore.instance.displayName ?? "");
const [avatarError, setAvatarError] = useState<boolean>(false);
const [maxUploadSize, setMaxUploadSize] = useState<number | undefined>();
const [displayNameError, setDisplayNameError] = useState<boolean>(false);
const toastRack = useToastContext();
const client = useMatrixClientContext();
useEffect(() => {
(async () => {
try {
const mediaConfig = await client.getMediaConfig();
setMaxUploadSize(mediaConfig["m.upload.size"]);
} catch (e) {
logger.warn("Failed to get media config", e);
}
})();
}, [client]);
const onAvatarRemove = useCallback(async () => {
const removeToast = toastRack.displayToast(
<SpinnerToast>{_t("settings|general|avatar_remove_progress")}</SpinnerToast>,
);
try {
await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
setAvatarURL("");
} finally {
removeToast();
}
}, [toastRack, client]);
const onAvatarChange = useCallback(
async (avatarFile: File) => {
PosthogTrackers.trackInteraction("WebProfileSettingsAvatarUploadButton");
logger.log(
`Uploading new avatar, ${avatarFile.name} of type ${avatarFile.type}, (${avatarFile.size}) bytes`,
);
const removeToast = toastRack.displayToast(
<SpinnerToast>{_t("settings|general|avatar_save_progress")}</SpinnerToast>,
);
try {
setAvatarError(false);
const { content_uri: uri } = await client.uploadContent(avatarFile);
await client.setAvatarUrl(uri);
setAvatarURL(uri);
} catch (e) {
setAvatarError(true);
} finally {
removeToast();
}
},
[toastRack, client],
);
const onDisplayNameChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value);
}, []);
const onDisplayNameCancel = useCallback(() => {
setDisplayName(OwnProfileStore.instance.displayName ?? "");
}, []);
const onDisplayNameSave = useCallback(async (): Promise<void> => {
try {
setDisplayNameError(false);
await client.setDisplayName(displayName);
} catch (e) {
setDisplayNameError(true);
throw e;
}
}, [displayName, client]);
const userIdentifier = useMemo(
() =>
UserIdentifierCustomisations.getDisplayUserIdentifier(client.getSafeUserId(), {
withDisplayName: true,
}),
[client],
);
const someFieldsDisabled = !canSetDisplayName || !canSetAvatar;
return (
<div className="mx_UserProfileSettings">
<h2>{_t("common|profile")}</h2>
<div>
{someFieldsDisabled
? _t("settings|general|profile_subtitle_oidc")
: _t("settings|general|profile_subtitle")}
</div>
<div className="mx_UserProfileSettings_profile">
<AvatarSetting
avatar={avatarURL ?? undefined}
avatarAltText={_t("common|user_avatar")}
onChange={onAvatarChange}
removeAvatar={avatarURL ? onAvatarRemove : undefined}
placeholderName={displayName}
placeholderId={client.getUserId() ?? ""}
disabled={!canSetAvatar}
/>
<EditInPlace
className="mx_UserProfileSettings_profile_displayName"
label={_t("settings|general|display_name")}
value={displayName}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onDisplayNameChanged}
onCancel={onDisplayNameCancel}
onSave={onDisplayNameSave}
disabled={!canSetDisplayName}
>
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
</EditInPlace>
</div>
{avatarError && (
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
{maxUploadSize === undefined
? _t("settings|general|avatar_upload_error_text_generic")
: _t("settings|general|avatar_upload_error_text", { size: formatBytes(maxUploadSize) })}
</Alert>
)}
{userIdentifier && <UsernameBox username={userIdentifier} />}
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
{externalAccountManagementUrl && (
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
)}
<SignOutButton />
</Flex>
</div>
);
};
export default UserProfileSettings;

View file

@ -0,0 +1,146 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { useState } from "react";
import { LocalNotificationSettings } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import Spinner from "../../elements/Spinner";
import SettingsSubsection from "../shared/SettingsSubsection";
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
import DeviceDetails from "./DeviceDetails";
import { DeviceExpandDetailsButton } from "./DeviceExpandDetailsButton";
import DeviceTile from "./DeviceTile";
import { DeviceVerificationStatusCard } from "./DeviceVerificationStatusCard";
import { ExtendedDevice } from "./types";
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
interface Props {
device?: ExtendedDevice;
isLoading: boolean;
isSigningOut: boolean;
localNotificationSettings?: LocalNotificationSettings;
// number of other sessions the user has
// excludes current session
otherSessionsCount: number;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
onVerifyCurrentDevice: () => void;
onSignOutCurrentDevice: () => void;
signOutAllOtherSessions?: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
}
type CurrentDeviceSectionHeadingProps = Pick<
Props,
"onSignOutCurrentDevice" | "signOutAllOtherSessions" | "otherSessionsCount"
> & {
disabled?: boolean;
};
const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> = ({
onSignOutCurrentDevice,
signOutAllOtherSessions,
otherSessionsCount,
disabled,
}) => {
const menuOptions = [
<IconizedContextMenuOption
key="sign-out"
label={_t("action|sign_out")}
onClick={onSignOutCurrentDevice}
isDestructive
/>,
...(signOutAllOtherSessions
? [
<IconizedContextMenuOption
key="sign-out-all-others"
label={_t("settings|sessions|sign_out_all_other_sessions", { otherSessionsCount })}
onClick={signOutAllOtherSessions}
isDestructive
/>,
]
: []),
];
return (
<SettingsSubsectionHeading heading={_t("settings|sessions|current_session")}>
<KebabContextMenu
disabled={disabled}
title={_t("common|options")}
options={menuOptions}
data-testid="current-session-menu"
/>
</SettingsSubsectionHeading>
);
};
const CurrentDeviceSection: React.FC<Props> = ({
device,
isLoading,
isSigningOut,
localNotificationSettings,
otherSessionsCount,
setPushNotifications,
onVerifyCurrentDevice,
onSignOutCurrentDevice,
signOutAllOtherSessions,
saveDeviceName,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<SettingsSubsection
data-testid="current-session-section"
heading={
<CurrentDeviceSectionHeading
onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
otherSessionsCount={otherSessionsCount}
disabled={isLoading || !device || isSigningOut}
/>
}
>
{/* only show big spinner on first load */}
{isLoading && !device && <Spinner />}
{!!device && (
<>
<DeviceTile device={device} onClick={() => setIsExpanded(!isExpanded)}>
<DeviceExpandDetailsButton
data-testid="current-session-toggle-details"
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
/>
</DeviceTile>
{isExpanded ? (
<DeviceDetails
device={device}
localNotificationSettings={localNotificationSettings}
setPushNotifications={setPushNotifications}
isSigningOut={isSigningOut}
onVerifyDevice={onVerifyCurrentDevice}
onSignOutDevice={onSignOutCurrentDevice}
saveDeviceName={saveDeviceName}
className="mx_CurrentDeviceSection_deviceDetails"
/>
) : (
<>
<br />
<DeviceVerificationStatusCard
device={device}
onVerifyDevice={onVerifyCurrentDevice}
isCurrentDevice
/>
</>
)}
</>
)}
</SettingsSubsection>
);
};
export default CurrentDeviceSection;

View file

@ -0,0 +1,130 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { useEffect, useState } from "react";
import { _t } from "../../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import Field from "../../elements/Field";
import LearnMore from "../../elements/LearnMore";
import Spinner from "../../elements/Spinner";
import { Caption } from "../../typography/Caption";
import Heading from "../../typography/Heading";
import { ExtendedDevice } from "./types";
interface Props {
device: ExtendedDevice;
saveDeviceName: (deviceName: string) => Promise<void>;
}
const DeviceNameEditor: React.FC<Props & { stopEditing: () => void }> = ({ device, saveDeviceName, stopEditing }) => {
const [deviceName, setDeviceName] = useState(device.display_name || "");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDeviceName(device.display_name || "");
}, [device.display_name]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>): void => setDeviceName(event.target.value);
const onSubmit = async (event: ButtonEvent): Promise<void> => {
setIsLoading(true);
setError(null);
event.preventDefault();
try {
await saveDeviceName(deviceName);
stopEditing();
} catch (error) {
setError(_t("settings|sessions|error_set_name"));
setIsLoading(false);
}
};
const headingId = `device-rename-${device.device_id}`;
const descriptionId = `device-rename-description-${device.device_id}`;
return (
<form aria-disabled={isLoading} className="mx_DeviceDetailHeading_renameForm" onSubmit={onSubmit} method="post">
<p id={headingId} className="mx_DeviceDetailHeading_renameFormHeading">
{_t("settings|sessions|rename_form_heading")}
</p>
<div>
<Field
data-testid="device-rename-input"
type="text"
value={deviceName}
autoComplete="off"
onChange={onInputChange}
autoFocus
disabled={isLoading}
aria-labelledby={headingId}
aria-describedby={descriptionId}
className="mx_DeviceDetailHeading_renameFormInput"
maxLength={100}
/>
<Caption id={descriptionId}>
{_t("settings|sessions|rename_form_caption")}
<LearnMore
title={_t("settings|sessions|rename_form_learn_more")}
description={
<>
<p>{_t("settings|sessions|rename_form_learn_more_description_1")}</p>
<p>{_t("settings|sessions|rename_form_learn_more_description_2")}</p>
</>
}
/>
{!!error && (
<span data-testid="device-rename-error" className="mx_DeviceDetailHeading_renameFormError">
{error}
</span>
)}
</Caption>
</div>
<div className="mx_DeviceDetailHeading_renameFormButtons">
<AccessibleButton
onClick={onSubmit}
kind="primary"
data-testid="device-rename-submit-cta"
disabled={isLoading}
>
{_t("action|save")}
</AccessibleButton>
<AccessibleButton
onClick={stopEditing}
kind="secondary"
data-testid="device-rename-cancel-cta"
disabled={isLoading}
>
{_t("action|cancel")}
</AccessibleButton>
{isLoading && <Spinner w={16} h={16} />}
</div>
</form>
);
};
export const DeviceDetailHeading: React.FC<Props> = ({ device, saveDeviceName }) => {
const [isEditing, setIsEditing] = useState(false);
return isEditing ? (
<DeviceNameEditor device={device} saveDeviceName={saveDeviceName} stopEditing={() => setIsEditing(false)} />
) : (
<div className="mx_DeviceDetailHeading" data-testid="device-detail-heading">
<Heading size="4">{device.display_name || device.device_id}</Heading>
<AccessibleButton
kind="link_inline"
onClick={() => setIsEditing(true)}
className="mx_DeviceDetailHeading_renameCta"
data-testid="device-heading-rename-cta"
>
{_t("action|rename")}
</AccessibleButton>
</div>
);
};

View file

@ -0,0 +1,181 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 classNames from "classnames";
import { IPusher, PUSHER_ENABLED, LocalNotificationSettings } from "matrix-js-sdk/src/matrix";
import { formatDate } from "../../../../DateUtils";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import Spinner from "../../elements/Spinner";
import ToggleSwitch from "../../elements/ToggleSwitch";
import { DeviceDetailHeading } from "./DeviceDetailHeading";
import { DeviceVerificationStatusCard } from "./DeviceVerificationStatusCard";
import { ExtendedDevice } from "./types";
interface Props {
device: ExtendedDevice;
pusher?: IPusher;
localNotificationSettings?: LocalNotificationSettings;
isSigningOut: boolean;
onVerifyDevice?: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void>;
supportsMSC3881?: boolean;
className?: string;
isCurrentDevice?: boolean;
}
interface MetadataTable {
id: string;
heading?: string;
values: { label: string; value?: string | React.ReactNode }[];
}
const DeviceDetails: React.FC<Props> = ({
device,
pusher,
localNotificationSettings,
isSigningOut,
onVerifyDevice,
onSignOutDevice,
saveDeviceName,
setPushNotifications,
supportsMSC3881,
className,
isCurrentDevice,
}) => {
const metadata: MetadataTable[] = [
{
id: "session",
values: [
{ label: _t("settings|sessions|session_id"), value: device.device_id },
{
label: _t("settings|sessions|last_activity"),
value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)),
},
],
},
{
id: "application",
heading: _t("common|application"),
values: [
{ label: _t("common|name"), value: device.appName },
{ label: _t("common|version"), value: device.appVersion },
{ label: _t("settings|sessions|url"), value: device.url },
],
},
{
id: "device",
heading: _t("common|device"),
values: [
{ label: _t("common|model"), value: device.deviceModel },
{ label: _t("settings|sessions|os"), value: device.deviceOperatingSystem },
{ label: _t("settings|sessions|browser"), value: device.client },
{ label: _t("settings|sessions|ip"), value: device.last_seen_ip },
],
},
]
.map((section) =>
// filter out falsy values
({ ...section, values: section.values.filter((row) => !!row.value) }),
)
.filter(
(section) =>
// then filter out sections with no values
section.values.length,
);
const showPushNotificationSection = !!pusher || !!localNotificationSettings;
function isPushNotificationsEnabled(pusher?: IPusher, notificationSettings?: LocalNotificationSettings): boolean {
if (pusher) return !!pusher[PUSHER_ENABLED.name];
if (localNotificationSettings) return !localNotificationSettings.is_silenced;
return true;
}
function isCheckboxDisabled(pusher?: IPusher, notificationSettings?: LocalNotificationSettings): boolean {
if (localNotificationSettings) return false;
if (pusher && !supportsMSC3881) return true;
return false;
}
return (
<div className={classNames("mx_DeviceDetails", className)} data-testid={`device-detail-${device.device_id}`}>
<section className="mx_DeviceDetails_section">
<DeviceDetailHeading device={device} saveDeviceName={saveDeviceName} />
<DeviceVerificationStatusCard device={device} onVerifyDevice={onVerifyDevice} isCurrentDevice />
</section>
<section className="mx_DeviceDetails_section">
<p className="mx_DeviceDetails_sectionHeading">{_t("settings|sessions|details_heading")}</p>
{metadata.map(({ heading, values, id }, index) => (
<table
className="mx_DeviceDetails_metadataTable"
key={index}
data-testid={`device-detail-metadata-${id}`}
>
{heading && (
<thead>
<tr>
<th>{heading}</th>
</tr>
</thead>
)}
<tbody>
{values.map(({ label, value }) => (
<tr key={label}>
<td className="mxDeviceDetails_metadataLabel">{label}</td>
<td className="mxDeviceDetails_metadataValue">{value}</td>
</tr>
))}
</tbody>
</table>
))}
</section>
{showPushNotificationSection && (
<section
className="mx_DeviceDetails_section mx_DeviceDetails_pushNotifications"
data-testid="device-detail-push-notification"
>
<ToggleSwitch
// For backwards compatibility, if `enabled` is missing
// default to `true`
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={(checked) => setPushNotifications?.(device.device_id, checked)}
title={_t("settings|sessions|push_toggle")}
data-testid="device-detail-push-notification-checkbox"
/>
<p className="mx_DeviceDetails_sectionHeading">
{_t("settings|sessions|push_heading")}
<small className="mx_DeviceDetails_sectionSubheading">
{_t("settings|sessions|push_subheading")}
</small>
</p>
</section>
)}
<section className="mx_DeviceDetails_section">
<AccessibleButton
onClick={onSignOutDevice}
kind="danger_inline"
disabled={isSigningOut}
data-testid="device-detail-sign-out-cta"
>
<span className="mx_DeviceDetails_signOutButtonContent">
{_t("settings|sessions|sign_out")}
{isSigningOut && <Spinner w={16} h={16} />}
</span>
</AccessibleButton>
</section>
</div>
);
};
export default DeviceDetails;

View file

@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { ComponentProps } from "react";
import { Icon as CaretIcon } from "../../../../../res/img/feather-customised/dropdown-arrow.svg";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
ComponentProps<typeof AccessibleButton<T>>,
"aria-label" | "title" | "kind" | "className" | "onClick" | "element"
> & {
isExpanded: boolean;
onClick: () => void;
};
export const DeviceExpandDetailsButton = <T extends keyof JSX.IntrinsicElements>({
isExpanded,
onClick,
...rest
}: Props<T>): JSX.Element => {
const label = isExpanded ? _t("settings|sessions|hide_details") : _t("settings|sessions|show_details");
return (
<AccessibleButton
{...rest}
aria-label={label}
title={label}
kind="icon"
className={classNames("mx_DeviceExpandDetailsButton", {
mx_DeviceExpandDetailsButton_expanded: isExpanded,
})}
onClick={onClick}
>
<CaretIcon className="mx_DeviceExpandDetailsButton_icon" />
</AccessibleButton>
);
};

View file

@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { Fragment } from "react";
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "../../../../components/views/settings/devices/filter";
import { ExtendedDevice } from "../../../../components/views/settings/devices/types";
import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import { _t } from "../../../../languageHandler";
interface Props {
device: ExtendedDevice;
}
const MS_DAY = 24 * 60 * 60 * 1000;
const MS_6_DAYS = 6 * MS_DAY;
const formatLastActivity = (timestamp: number, now = new Date().getTime()): string => {
// less than a week ago
if (timestamp + MS_6_DAYS >= now) {
const date = new Date(timestamp);
// Tue 20:15
return formatDate(date);
}
return formatRelativeTime(new Date(timestamp));
};
const getInactiveMetadata = (device: ExtendedDevice): { id: string; value: React.ReactNode } | undefined => {
const isInactive = isDeviceInactive(device);
if (!isInactive || !device.last_seen_ts) {
return undefined;
}
return {
id: "inactive",
value: (
<>
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
{_t("settings|sessions|inactive_days", { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
` (${formatLastActivity(device.last_seen_ts)})`}
</>
),
};
};
const DeviceMetaDatum: React.FC<{ value: string | React.ReactNode; id: string }> = ({ value, id }) =>
value ? <span data-testid={`device-metadata-${id}`}>{value}</span> : null;
export const DeviceMetaData: React.FC<Props> = ({ device }) => {
const inactive = getInactiveMetadata(device);
const lastActivity =
device.last_seen_ts && `${_t("settings|sessions|last_activity")} ${formatLastActivity(device.last_seen_ts)}`;
const verificationStatus = device.isVerified ? _t("common|verified") : _t("common|unverified");
// if device is inactive, don't display last activity or verificationStatus
const metadata = inactive
? [inactive, { id: "lastSeenIp", value: device.last_seen_ip }]
: [
{ id: "isVerified", value: verificationStatus },
{ id: "lastActivity", value: lastActivity },
{ id: "lastSeenIp", value: device.last_seen_ip },
{ id: "deviceId", value: device.device_id },
];
return (
<>
{metadata.map(({ id, value }, index) =>
!!value ? (
<Fragment key={id}>
{!!index && " · "}
<DeviceMetaDatum id={id} value={value} />
</Fragment>
) : null,
)}
</>
);
};

View file

@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React from "react";
import { Icon as VerifiedIcon } from "../../../../../res/img/e2e/verified.svg";
import { Icon as UnverifiedIcon } from "../../../../../res/img/e2e/warning.svg";
import { Icon as InactiveIcon } from "../../../../../res/img/element-icons/settings/inactive.svg";
import { DeviceSecurityVariation } from "./types";
interface Props {
variation: DeviceSecurityVariation;
heading: string;
description: string | React.ReactNode;
children?: React.ReactNode;
}
const VariationIcon: Record<DeviceSecurityVariation, React.FC<React.SVGProps<SVGSVGElement>>> = {
[DeviceSecurityVariation.Inactive]: InactiveIcon,
[DeviceSecurityVariation.Verified]: VerifiedIcon,
[DeviceSecurityVariation.Unverified]: UnverifiedIcon,
[DeviceSecurityVariation.Unverifiable]: UnverifiedIcon,
};
const DeviceSecurityIcon: React.FC<{ variation: DeviceSecurityVariation }> = ({ variation }) => {
const Icon = VariationIcon[variation];
return (
<div className={classNames("mx_DeviceSecurityCard_icon", variation)}>
<Icon height={16} width={16} />
</div>
);
};
const DeviceSecurityCard: React.FC<Props> = ({ variation, heading, description, children }) => {
return (
<div className="mx_DeviceSecurityCard">
<DeviceSecurityIcon variation={variation} />
<div className="mx_DeviceSecurityCard_content">
<p className="mx_DeviceSecurityCard_heading">{heading}</p>
<p className="mx_DeviceSecurityCard_description">{description}</p>
{!!children && <div className="mx_DeviceSecurityCard_actions">{children}</div>}
</div>
</div>
);
};
export default DeviceSecurityCard;

View file

@ -0,0 +1,73 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps } from "react";
import { _t } from "../../../../languageHandler";
import LearnMore from "../../elements/LearnMore";
import { DeviceSecurityVariation } from "./types";
type Props = Omit<ComponentProps<typeof LearnMore>, "title" | "description"> & {
variation: DeviceSecurityVariation;
};
const securityCardContent: Record<
DeviceSecurityVariation,
{
title: string;
description: React.ReactNode | string;
}
> = {
[DeviceSecurityVariation.Verified]: {
title: _t("settings|sessions|verified_sessions"),
description: (
<>
<p>{_t("settings|sessions|verified_sessions_explainer_1")}</p>
<p>{_t("settings|sessions|verified_sessions_explainer_2")}</p>
</>
),
},
[DeviceSecurityVariation.Unverified]: {
title: _t("settings|sessions|unverified_sessions"),
description: (
<>
<p>{_t("settings|sessions|unverified_sessions_explainer_1")}</p>
<p>{_t("settings|sessions|unverified_sessions_explainer_2")}</p>
</>
),
},
// unverifiable uses single-session case
// because it is only ever displayed on a single session detail
[DeviceSecurityVariation.Unverifiable]: {
title: _t("settings|sessions|unverified_session"),
description: (
<>
<p>{_t("settings|sessions|unverified_session_explainer_1")}</p>
<p>{_t("settings|sessions|unverified_session_explainer_2")}</p>
<p>{_t("settings|sessions|unverified_session_explainer_3")}</p>
</>
),
},
[DeviceSecurityVariation.Inactive]: {
title: _t("settings|sessions|inactive_sessions"),
description: (
<>
<p>{_t("settings|sessions|inactive_sessions_explainer_1")}</p>
<p>{_t("settings|sessions|inactive_sessions_explainer_2")}</p>
</>
),
},
};
/**
* LearnMore with content for device security warnings
*/
export const DeviceSecurityLearnMore: React.FC<Props> = ({ variation }) => {
const { title, description } = securityCardContent[variation];
return <LearnMore title={title} description={description} />;
};

View file

@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
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 classNames from "classnames";
import Heading from "../../typography/Heading";
import { ExtendedDevice } from "./types";
import { DeviceTypeIcon } from "./DeviceTypeIcon";
import { preventDefaultWrapper } from "../../../../utils/NativeEventUtils";
import { DeviceMetaData } from "./DeviceMetaData";
export interface DeviceTileProps {
device: ExtendedDevice;
isSelected?: boolean;
children?: React.ReactNode;
onClick?: () => void;
}
const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => {
return <Heading size="4">{device.display_name || device.device_id}</Heading>;
};
const DeviceTile: React.FC<DeviceTileProps> = ({ device, children, isSelected, onClick }) => {
return (
<div
className={classNames("mx_DeviceTile", { mx_DeviceTile_interactive: !!onClick })}
data-testid={`device-tile-${device.device_id}`}
onClick={onClick}
>
<DeviceTypeIcon isVerified={device.isVerified} isSelected={isSelected} deviceType={device.deviceType} />
<div className="mx_DeviceTile_info">
<DeviceTileName device={device} />
<div className="mx_DeviceTile_metadata">
<DeviceMetaData device={device} />
</div>
</div>
<div className="mx_DeviceTile_actions" onClick={preventDefaultWrapper(() => {})}>
{children}
</div>
</div>
);
};
export default DeviceTile;

View file

@ -0,0 +1,68 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 classNames from "classnames";
import { Icon as UnknownDeviceIcon } from "../../../../../res/img/element-icons/settings/unknown-device.svg";
import { Icon as DesktopIcon } from "../../../../../res/img/element-icons/settings/desktop.svg";
import { Icon as WebIcon } from "../../../../../res/img/element-icons/settings/web.svg";
import { Icon as MobileIcon } from "../../../../../res/img/element-icons/settings/mobile.svg";
import { Icon as VerifiedIcon } from "../../../../../res/img/e2e/verified.svg";
import { Icon as UnverifiedIcon } from "../../../../../res/img/e2e/warning.svg";
import { _t, _td, TranslationKey } from "../../../../languageHandler";
import { ExtendedDevice } from "./types";
import { DeviceType } from "../../../../utils/device/parseUserAgent";
interface Props {
isVerified?: ExtendedDevice["isVerified"];
isSelected?: boolean;
deviceType?: DeviceType;
}
const deviceTypeIcon: Record<DeviceType, React.FC<React.SVGProps<SVGSVGElement>>> = {
[DeviceType.Desktop]: DesktopIcon,
[DeviceType.Mobile]: MobileIcon,
[DeviceType.Web]: WebIcon,
[DeviceType.Unknown]: UnknownDeviceIcon,
};
const deviceTypeLabel: Record<DeviceType, TranslationKey> = {
[DeviceType.Desktop]: _td("settings|sessions|desktop_session"),
[DeviceType.Mobile]: _td("settings|sessions|mobile_session"),
[DeviceType.Web]: _td("settings|sessions|web_session"),
[DeviceType.Unknown]: _td("settings|sessions|unknown_session"),
};
export const DeviceTypeIcon: React.FC<Props> = ({ isVerified, isSelected, deviceType }) => {
const Icon = deviceTypeIcon[deviceType!] || deviceTypeIcon[DeviceType.Unknown];
const label = _t(deviceTypeLabel[deviceType!] || deviceTypeLabel[DeviceType.Unknown]);
return (
<div
className={classNames("mx_DeviceTypeIcon", {
mx_DeviceTypeIcon_selected: isSelected,
})}
>
<div className="mx_DeviceTypeIcon_deviceIconWrapper">
<Icon className="mx_DeviceTypeIcon_deviceIcon" role="img" aria-label={label} />
</div>
{isVerified ? (
<VerifiedIcon
className={classNames("mx_DeviceTypeIcon_verificationIcon", "verified")}
role="img"
aria-label={_t("common|verified")}
/>
) : (
<UnverifiedIcon
className={classNames("mx_DeviceTypeIcon_verificationIcon", "unverified")}
role="img"
aria-label={_t("common|unverified")}
/>
)}
</div>
);
};

View file

@ -0,0 +1,95 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
import { DeviceSecurityVariation, ExtendedDevice } from "./types";
export interface DeviceVerificationStatusCardProps {
device: ExtendedDevice;
isCurrentDevice?: boolean;
onVerifyDevice?: () => void;
}
const getCardProps = (
device: ExtendedDevice,
isCurrentDevice?: boolean,
): {
variation: DeviceSecurityVariation;
heading: string;
description: React.ReactNode;
} => {
if (device.isVerified) {
const descriptionText = isCurrentDevice
? _t("settings|sessions|device_verified_description_current")
: _t("settings|sessions|device_verified_description");
return {
variation: DeviceSecurityVariation.Verified,
heading: _t("settings|sessions|verified_session"),
description: (
<>
{descriptionText}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
</>
),
};
}
if (device.isVerified === null) {
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t("settings|sessions|unverified_session"),
description: (
<>
{_t("settings|sessions|unverified_session_explainer_1")}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverifiable} />
</>
),
};
}
const descriptionText = isCurrentDevice
? _t("settings|sessions|device_unverified_description_current")
: _t("settings|sessions|device_unverified_description");
return {
variation: DeviceSecurityVariation.Unverified,
heading: _t("settings|sessions|unverified_session"),
description: (
<>
{descriptionText}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>
),
};
};
export const DeviceVerificationStatusCard: React.FC<DeviceVerificationStatusCardProps> = ({
device,
isCurrentDevice,
onVerifyDevice,
}) => {
const securityCardProps = getCardProps(device, isCurrentDevice);
return (
<DeviceSecurityCard {...securityCardProps}>
{/* check for explicit false to exclude unverifiable devices */}
{device.isVerified === false && !!onVerifyDevice && (
<AccessibleButton
kind="primary"
onClick={onVerifyDevice}
data-testid={`verification-status-button-${device.device_id}`}
>
{_t("settings|sessions|verify_session")}
</AccessibleButton>
)}
</DeviceSecurityCard>
);
};

View file

@ -0,0 +1,382 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { ForwardedRef, forwardRef } from "react";
import { IPusher, PUSHER_DEVICE_ID, LocalNotificationSettings } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import { FilterDropdown, FilterDropdownOption } from "../../elements/FilterDropdown";
import DeviceDetails from "./DeviceDetails";
import { DeviceExpandDetailsButton } from "./DeviceExpandDetailsButton";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from "./filter";
import SelectableDeviceTile from "./SelectableDeviceTile";
import { DevicesDictionary, DeviceSecurityVariation, ExtendedDevice } from "./types";
import { DevicesState } from "./useOwnDevices";
import FilteredDeviceListHeader from "./FilteredDeviceListHeader";
import Spinner from "../../elements/Spinner";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
import DeviceTile from "./DeviceTile";
interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
expandedDeviceIds: ExtendedDevice["device_id"][];
signingOutDeviceIds: ExtendedDevice["device_id"][];
selectedDeviceIds: ExtendedDevice["device_id"][];
filter?: FilterVariation;
onFilterChange: (filter: FilterVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: ExtendedDevice["device_id"]) => void;
onSignOutDevices: (deviceIds: ExtendedDevice["device_id"][]) => void;
saveDeviceName: DevicesState["saveDeviceName"];
onRequestDeviceVerification?: (deviceId: ExtendedDevice["device_id"]) => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
setSelectedDeviceIds: (deviceIds: ExtendedDevice["device_id"][]) => void;
supportsMSC3881?: boolean | undefined;
/**
* Only allow sessions to be signed out individually
* Removes checkboxes and multi selection header
*/
disableMultipleSignout?: boolean;
}
const isDeviceSelected = (
deviceId: ExtendedDevice["device_id"],
selectedDeviceIds: ExtendedDevice["device_id"][],
): boolean => selectedDeviceIds.includes(deviceId);
// devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivityThenDisplayName = (left: ExtendedDevice, right: ExtendedDevice): number =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0) ||
(left.display_name || left.device_id).localeCompare(right.display_name || right.device_id);
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: FilterVariation): ExtendedDevice[] =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []).sort(
sortDevicesByLatestActivityThenDisplayName,
);
const ALL_FILTER_ID = "ALL";
type DeviceFilterKey = FilterVariation | typeof ALL_FILTER_ID;
const isSecurityVariation = (filter?: DeviceFilterKey): filter is FilterVariation =>
!!filter &&
(
[
DeviceSecurityVariation.Inactive,
DeviceSecurityVariation.Unverified,
DeviceSecurityVariation.Verified,
] as string[]
).includes(filter);
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
if (isSecurityVariation(filter)) {
const securityCardContent: Record<
DeviceSecurityVariation,
{
title: string;
description: string;
}
> = {
[DeviceSecurityVariation.Verified]: {
title: _t("settings|sessions|verified_sessions"),
description: _t("settings|sessions|verified_sessions_list_description"),
},
[DeviceSecurityVariation.Unverified]: {
title: _t("settings|sessions|unverified_sessions"),
description: _t("settings|sessions|unverified_sessions_list_description"),
},
[DeviceSecurityVariation.Unverifiable]: {
title: _t("settings|sessions|unverified_session"),
description: _t("settings|sessions|unverified_session_explainer_1"),
},
[DeviceSecurityVariation.Inactive]: {
title: _t("settings|sessions|inactive_sessions"),
description: _t("settings|sessions|inactive_sessions_list_description", {
inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS,
}),
},
};
const { title, description } = securityCardContent[filter];
return (
<div className="mx_FilteredDeviceList_securityCard">
<DeviceSecurityCard
variation={filter}
heading={title}
description={
<span>
{description}
<DeviceSecurityLearnMore variation={filter} />
</span>
}
/>
</div>
);
}
return null;
};
const getNoResultsMessage = (filter?: FilterVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t("settings|sessions|no_verified_sessions");
case DeviceSecurityVariation.Unverified:
return _t("settings|sessions|no_unverified_sessions");
case DeviceSecurityVariation.Inactive:
return _t("settings|sessions|no_inactive_sessions");
default:
return _t("settings|sessions|no_sessions");
}
};
interface NoResultsProps {
filter?: FilterVariation;
clearFilter: () => void;
}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) => (
<div className="mx_FilteredDeviceList_noResults">
{getNoResultsMessage(filter)}
{
/* No clear filter button when filter is falsy (ie 'All') */
!!filter && (
<>
&nbsp;
<AccessibleButton kind="link_inline" onClick={clearFilter} data-testid="devices-clear-filter-btn">
{_t("action|show_all")}
</AccessibleButton>
</>
)
}
</div>
);
const DeviceListItem: React.FC<{
device: ExtendedDevice;
pusher?: IPusher | undefined;
localNotificationSettings?: LocalNotificationSettings | undefined;
isExpanded: boolean;
isSigningOut: boolean;
isSelected: boolean;
onDeviceExpandToggle: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
onRequestDeviceVerification?: () => void;
toggleSelected: () => void;
setPushNotifications: (deviceId: string, enabled: boolean) => Promise<void>;
supportsMSC3881?: boolean | undefined;
isSelectDisabled?: boolean;
}> = ({
device,
pusher,
localNotificationSettings,
isExpanded,
isSigningOut,
isSelected,
onDeviceExpandToggle,
onSignOutDevice,
saveDeviceName,
onRequestDeviceVerification,
setPushNotifications,
toggleSelected,
supportsMSC3881,
isSelectDisabled,
}) => {
const tileContent = (
<>
{isSigningOut && <Spinner w={16} h={16} />}
<DeviceExpandDetailsButton isExpanded={isExpanded} onClick={onDeviceExpandToggle} />
</>
);
return (
<li className="mx_FilteredDeviceList_listItem">
{isSelectDisabled ? (
<DeviceTile device={device} onClick={onDeviceExpandToggle}>
{tileContent}
</DeviceTile>
) : (
<SelectableDeviceTile
isSelected={isSelected}
onSelect={toggleSelected}
onClick={onDeviceExpandToggle}
device={device}
>
{tileContent}
</SelectableDeviceTile>
)}
{isExpanded && (
<DeviceDetails
device={device}
pusher={pusher}
localNotificationSettings={localNotificationSettings}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
saveDeviceName={saveDeviceName}
setPushNotifications={setPushNotifications}
supportsMSC3881={supportsMSC3881}
className="mx_FilteredDeviceList_deviceDetails"
/>
)}
</li>
);
};
/**
* Filtered list of devices
* Sorted by latest activity descending
*/
export const FilteredDeviceList = forwardRef(
(
{
devices,
pushers,
localNotificationSettings,
filter,
expandedDeviceIds,
signingOutDeviceIds,
selectedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
saveDeviceName,
onSignOutDevices,
onRequestDeviceVerification,
setPushNotifications,
setSelectedDeviceIds,
supportsMSC3881,
disableMultipleSignout,
}: Props,
ref: ForwardedRef<HTMLDivElement>,
) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: ExtendedDevice["device_id"]): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter((id) => id !== deviceId));
} else {
setSelectedDeviceIds([...selectedDeviceIds, deviceId]);
}
};
const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t("settings|sessions|filter_all") },
{
id: DeviceSecurityVariation.Verified,
label: _t("common|verified"),
description: _t("settings|sessions|filter_verified_description"),
},
{
id: DeviceSecurityVariation.Unverified,
label: _t("common|unverified"),
description: _t("settings|sessions|filter_unverified_description"),
},
{
id: DeviceSecurityVariation.Inactive,
label: _t("settings|sessions|filter_inactive"),
description: _t("settings|sessions|filter_inactive_description", {
inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS,
}),
},
];
const onFilterOptionChange = (filterId: DeviceFilterKey): void => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : (filterId as FilterVariation));
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
const toggleSelectAll = (): void => {
if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map((device) => device.device_id));
}
};
const isSigningOut = !!signingOutDeviceIds.length;
return (
<div className="mx_FilteredDeviceList" ref={ref}>
<FilteredDeviceListHeader
selectedDeviceCount={selectedDeviceIds.length}
isAllSelected={isAllSelected}
toggleSelectAll={toggleSelectAll}
isSelectDisabled={disableMultipleSignout}
>
{selectedDeviceIds.length ? (
<>
<AccessibleButton
data-testid="sign-out-selection-cta"
kind="danger_inline"
disabled={isSigningOut}
onClick={() => onSignOutDevices(selectedDeviceIds)}
className="mx_FilteredDeviceList_headerButton"
>
{isSigningOut && <Spinner w={16} h={16} />}
{_t("action|sign_out")}
</AccessibleButton>
<AccessibleButton
data-testid="cancel-selection-cta"
kind="content_inline"
disabled={isSigningOut}
onClick={() => setSelectedDeviceIds([])}
className="mx_FilteredDeviceList_headerButton"
>
{_t("action|cancel")}
</AccessibleButton>
</>
) : (
<FilterDropdown<DeviceFilterKey>
id="device-list-filter"
label={_t("settings|sessions|filter_label")}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t("action|show")}
/>
)}
</FilteredDeviceListHeader>
{!!sortedDevices.length ? (
<FilterSecurityCard filter={filter} />
) : (
<NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
)}
<ol className="mx_FilteredDeviceList_list">
{sortedDevices.map((device) => (
<DeviceListItem
key={device.device_id}
device={device}
pusher={getPusherForDevice(device)}
localNotificationSettings={localNotificationSettings.get(device.device_id)}
isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
isSelectDisabled={disableMultipleSignout}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
onSignOutDevice={() => onSignOutDevices([device.device_id])}
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
onRequestDeviceVerification={
onRequestDeviceVerification
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
setPushNotifications={setPushNotifications}
toggleSelected={() => toggleSelection(device.device_id)}
supportsMSC3881={supportsMSC3881}
/>
))}
</ol>
</div>
);
},
);

View file

@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { HTMLProps } from "react";
import { Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../../languageHandler";
import StyledCheckbox, { CheckboxStyle } from "../../elements/StyledCheckbox";
interface Props extends Omit<HTMLProps<HTMLDivElement>, "className"> {
selectedDeviceCount: number;
isAllSelected: boolean;
isSelectDisabled?: boolean;
toggleSelectAll: () => void;
children?: React.ReactNode;
}
const FilteredDeviceListHeader: React.FC<Props> = ({
selectedDeviceCount,
isAllSelected,
isSelectDisabled,
toggleSelectAll,
children,
...rest
}) => {
const checkboxLabel = isAllSelected ? _t("common|deselect_all") : _t("common|select_all");
return (
<div className="mx_FilteredDeviceListHeader" {...rest}>
{!isSelectDisabled && (
<Tooltip label={checkboxLabel} placement="top" isTriggerInteractive={false}>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isAllSelected}
onChange={toggleSelectAll}
id="device-select-all-checkbox"
data-testid="device-select-all-checkbox"
aria-label={checkboxLabel}
/>
</Tooltip>
)}
<span className="mx_FilteredDeviceListHeader_label">
{selectedDeviceCount > 0
? _t("settings|sessions|n_sessions_selected", { count: selectedDeviceCount })
: _t("settings|sessions|title")}
</span>
{children}
</div>
);
};
export default FilteredDeviceListHeader;

View file

@ -0,0 +1,100 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 {
IGetLoginTokenCapability,
IServerVersions,
GET_LOGIN_TOKEN_CAPABILITY,
Capabilities,
IClientWellKnown,
OidcClientConfig,
MatrixClient,
DEVICE_CODE_SCOPE,
} from "matrix-js-sdk/src/matrix";
import QrCodeIcon from "@vector-im/compound-design-tokens/assets/web/icons/qr-code";
import { Text } from "@vector-im/compound-web";
import { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsSubsection from "../shared/SettingsSubsection";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
interface IProps {
onShowQr: () => void;
versions?: IServerVersions;
capabilities?: Capabilities;
wellKnown?: IClientWellKnown;
oidcClientConfig?: OidcClientConfig;
isCrossSigningReady?: boolean;
}
function shouldShowQrLegacy(
versions?: IServerVersions,
wellKnown?: IClientWellKnown,
capabilities?: Capabilities,
): boolean {
// Needs server support for (get_login_token or OIDC Device Authorization Grant) and MSC3886:
// in r0 of MSC3882 it is exposed as a feature flag, but in stable and unstable r1 it is a capability
const loginTokenCapability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
const getLoginTokenSupported =
!!versions?.unstable_features?.["org.matrix.msc3882"] || !!loginTokenCapability?.enabled;
const msc3886Supported =
!!versions?.unstable_features?.["org.matrix.msc3886"] || !!wellKnown?.["io.element.rendezvous"]?.server;
return getLoginTokenSupported && msc3886Supported;
}
export function shouldShowQr(
cli: MatrixClient,
isCrossSigningReady: boolean,
oidcClientConfig?: OidcClientConfig,
versions?: IServerVersions,
wellKnown?: IClientWellKnown,
): boolean {
const msc4108Supported =
!!versions?.unstable_features?.["org.matrix.msc4108"] || !!wellKnown?.["io.element.rendezvous"]?.server;
const deviceAuthorizationGrantSupported =
oidcClientConfig?.metadata?.grant_types_supported.includes(DEVICE_CODE_SCOPE);
return (
!!deviceAuthorizationGrantSupported &&
msc4108Supported &&
!!cli.getCrypto()?.exportSecretsBundle &&
isCrossSigningReady
);
}
const LoginWithQRSection: React.FC<IProps> = ({
onShowQr,
versions,
capabilities,
wellKnown,
oidcClientConfig,
isCrossSigningReady,
}) => {
const cli = useMatrixClientContext();
const offerShowQr = oidcClientConfig
? shouldShowQr(cli, !!isCrossSigningReady, oidcClientConfig, versions, wellKnown)
: shouldShowQrLegacy(versions, wellKnown, capabilities);
return (
<SettingsSubsection heading={_t("settings|sessions|sign_in_with_qr")}>
<div className="mx_LoginWithQRSection">
<p className="mx_SettingsTab_subsectionText">{_t("settings|sessions|sign_in_with_qr_description")}</p>
<AccessibleButton onClick={onShowQr} kind="primary" disabled={!offerShowQr}>
<QrCodeIcon height={20} width={20} />
{_t("settings|sessions|sign_in_with_qr_button")}
</AccessibleButton>
{!offerShowQr && <Text size="sm">{_t("settings|sessions|sign_in_with_qr_unsupported")}</Text>}
</div>
</SettingsSubsection>
);
};
export default LoginWithQRSection;

View file

@ -0,0 +1,54 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { _t } from "../../../../languageHandler";
import { KebabContextMenu } from "../../context_menus/KebabContextMenu";
import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading";
import { IconizedContextMenuOption } from "../../context_menus/IconizedContextMenu";
import { filterBoolean } from "../../../../utils/arrays";
interface Props {
// total count of other sessions
// excludes current sessions
// not affected by filters
otherSessionsCount: number;
disabled?: boolean;
// not provided when sign out all other sessions is not available
signOutAllOtherSessions?: () => void;
}
export const OtherSessionsSectionHeading: React.FC<Props> = ({
otherSessionsCount,
disabled,
signOutAllOtherSessions,
}) => {
const menuOptions = filterBoolean([
signOutAllOtherSessions ? (
<IconizedContextMenuOption
key="sign-out-all-others"
label={_t("settings|sessions|sign_out_n_sessions", { count: otherSessionsCount })}
onClick={signOutAllOtherSessions}
isDestructive
/>
) : null,
]);
return (
<SettingsSubsectionHeading heading={_t("settings|sessions|other_sessions_heading")}>
{!!menuOptions.length && (
<KebabContextMenu
disabled={disabled}
title={_t("common|options")}
options={menuOptions}
data-testid="other-sessions-menu"
/>
)}
</SettingsSubsectionHeading>
);
};

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { _t } from "../../../../languageHandler";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsSubsection from "../shared/SettingsSubsection";
import DeviceSecurityCard from "./DeviceSecurityCard";
import { DeviceSecurityLearnMore } from "./DeviceSecurityLearnMore";
import { filterDevicesBySecurityRecommendation, FilterVariation, INACTIVE_DEVICE_AGE_DAYS } from "./filter";
import { DeviceSecurityVariation, ExtendedDevice, DevicesDictionary } from "./types";
interface Props {
devices: DevicesDictionary;
currentDeviceId: ExtendedDevice["device_id"];
goToFilteredList: (filter: FilterVariation) => void;
}
const SecurityRecommendations: React.FC<Props> = ({ devices, currentDeviceId, goToFilteredList }) => {
const devicesArray = Object.values<ExtendedDevice>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(devicesArray, [
DeviceSecurityVariation.Unverified,
])
// filter out the current device
// as unverfied warning and actions
// will be shown in current session section
.filter((device) => device.device_id !== currentDeviceId).length;
const inactiveDevicesCount = filterDevicesBySecurityRecommendation(devicesArray, [
DeviceSecurityVariation.Inactive,
]).length;
if (!(unverifiedDevicesCount | inactiveDevicesCount)) {
return null;
}
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
return (
<SettingsSubsection
heading={_t("settings|sessions|security_recommendations")}
description={_t("settings|sessions|security_recommendations_description")}
data-testid="security-recommendations-section"
>
{!!unverifiedDevicesCount && (
<DeviceSecurityCard
variation={DeviceSecurityVariation.Unverified}
heading={_t("settings|sessions|unverified_sessions")}
description={
<>
{_t("settings|sessions|unverified_sessions_list_description")}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
</>
}
>
<AccessibleButton
kind="link_inline"
onClick={() => goToFilteredList(DeviceSecurityVariation.Unverified)}
data-testid="unverified-devices-cta"
>
{_t("action|view_all") + ` (${unverifiedDevicesCount})`}
</AccessibleButton>
</DeviceSecurityCard>
)}
{!!inactiveDevicesCount && (
<>
{!!unverifiedDevicesCount && <div className="mx_SecurityRecommendations_spacing" />}
<DeviceSecurityCard
variation={DeviceSecurityVariation.Inactive}
heading={_t("settings|sessions|inactive_sessions")}
description={
<>
{_t("settings|sessions|inactive_sessions_list_description", { inactiveAgeDays })}
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Inactive} />
</>
}
>
<AccessibleButton
kind="link_inline"
onClick={() => goToFilteredList(DeviceSecurityVariation.Inactive)}
data-testid="inactive-devices-cta"
>
{_t("action|view_all") + ` (${inactiveDevicesCount})`}
</AccessibleButton>
</DeviceSecurityCard>
</>
)}
</SettingsSubsection>
);
};
export default SecurityRecommendations;

View file

@ -0,0 +1,39 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 StyledCheckbox, { CheckboxStyle } from "../../elements/StyledCheckbox";
import DeviceTile, { DeviceTileProps } from "./DeviceTile";
interface Props extends DeviceTileProps {
isSelected: boolean;
onSelect: () => void;
onClick?: () => void;
}
const SelectableDeviceTile: React.FC<Props> = ({ children, device, isSelected, onSelect, onClick }) => {
return (
<div className="mx_SelectableDeviceTile">
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isSelected}
onChange={onSelect}
className="mx_SelectableDeviceTile_checkbox"
id={`device-tile-checkbox-${device.device_id}`}
data-testid={`device-tile-checkbox-${device.device_id}`}
>
<DeviceTile device={device} onClick={onClick} isSelected={isSelected}>
{children}
</DeviceTile>
</StyledCheckbox>
</div>
);
};
export default SelectableDeviceTile;

View file

@ -0,0 +1,77 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { AuthDict, IAuthData } from "matrix-js-sdk/src/interactive-auth";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import { InteractiveAuthCallback } from "../../../structures/InteractiveAuth";
import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents";
import InteractiveAuthDialog from "../../dialogs/InteractiveAuthDialog";
const makeDeleteRequest =
(matrixClient: MatrixClient, deviceIds: string[]) =>
async (auth: AuthDict | null): Promise<IAuthData> => {
return matrixClient.deleteMultipleDevices(deviceIds, auth ?? undefined);
};
export const deleteDevicesWithInteractiveAuth = async (
matrixClient: MatrixClient,
deviceIds: string[],
onFinished: InteractiveAuthCallback<void>,
): Promise<void> => {
if (!deviceIds.length) {
return;
}
try {
await makeDeleteRequest(matrixClient, deviceIds)(null);
// no interactive auth needed
await onFinished(true, undefined);
} catch (error) {
if (!(error instanceof MatrixError) || error.httpStatus !== 401 || !error.data?.flows) {
// doesn't look like an interactive-auth failure
throw error;
}
// pop up an interactive auth dialog
const numDevices = deviceIds.length;
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("settings|sessions|confirm_sign_out_sso", {
count: numDevices,
}),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("settings|sessions|confirm_sign_out", {
count: numDevices,
}),
body: _t("settings|sessions|confirm_sign_out_body", {
count: numDevices,
}),
continueText: _t("settings|sessions|confirm_sign_out_continue", { count: numDevices }),
continueKind: "danger",
},
};
Modal.createDialog(InteractiveAuthDialog, {
title: _t("common|authentication"),
matrixClient: matrixClient,
authData: error.data as IAuthData,
onFinished,
makeRequest: makeDeleteRequest(matrixClient, deviceIds),
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
}
};

View file

@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ExtendedDevice, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: ExtendedDevice) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e9; // 90 days
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
export type FilterVariation =
| DeviceSecurityVariation.Verified
| DeviceSecurityVariation.Inactive
| DeviceSecurityVariation.Unverified;
export const isDeviceInactive: DeviceFilterCondition = (device) =>
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
const filters: Record<FilterVariation, DeviceFilterCondition> = {
[DeviceSecurityVariation.Verified]: (device) => !!device.isVerified,
[DeviceSecurityVariation.Unverified]: (device) => !device.isVerified,
[DeviceSecurityVariation.Inactive]: isDeviceInactive,
};
export const filterDevicesBySecurityRecommendation = (
devices: ExtendedDevice[],
securityVariations: FilterVariation[],
): ExtendedDevice[] => {
const activeFilters = securityVariations.map((variation) => filters[variation]);
if (!activeFilters.length) {
return devices;
}
return devices.filter((device) => activeFilters.every((filter) => filter(device)));
};

View file

@ -0,0 +1,36 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { IMyDevice } from "matrix-js-sdk/src/matrix";
import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent";
export type DeviceWithVerification = IMyDevice & {
/**
* `null` if the device is unknown or has not published encryption keys; otherwise a boolean
* indicating whether the device has been cross-signed by a cross-signing key we trust.
*/
isVerified: boolean | null;
};
export type ExtendedDeviceAppInfo = {
// eg Element Web
appName?: string;
appVersion?: string;
url?: string;
};
export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation;
export type DevicesDictionary = Record<ExtendedDevice["device_id"], ExtendedDevice>;
export enum DeviceSecurityVariation {
Verified = "Verified",
Unverified = "Unverified",
Inactive = "Inactive",
// sessions that do not support encryption
// eg a session that logged in via api to get an access token
Unverifiable = "Unverifiable",
}

View file

@ -0,0 +1,252 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useContext, useEffect, useState } from "react";
import {
ClientEvent,
IMyDevice,
IPusher,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixClient,
MatrixEvent,
PUSHER_DEVICE_ID,
PUSHER_ENABLED,
UNSTABLE_MSC3852_LAST_SEEN_UA,
MatrixError,
LocalNotificationSettings,
} from "matrix-js-sdk/src/matrix";
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { _t } from "../../../../languageHandler";
import { getDeviceClientInformation, pruneClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
import { isDeviceVerified } from "../../../../utils/device/isDeviceVerified";
import { SDKContext } from "../../../../contexts/SDKContext";
const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => {
const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id);
return {
appName: name,
appVersion: version,
url,
};
};
/**
* Fetch extended details of the user's own devices
*
* @param matrixClient - Matrix Client
* @returns A dictionary mapping from device ID to ExtendedDevice
*/
export async function fetchExtendedDeviceInformation(matrixClient: MatrixClient): Promise<DevicesDictionary> {
const { devices } = await matrixClient.getDevices();
const devicesDict: DevicesDictionary = {};
for (const device of devices) {
devicesDict[device.device_id] = {
...device,
isVerified: await isDeviceVerified(matrixClient, device.device_id),
...parseDeviceExtendedInformation(matrixClient, device),
...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]),
};
}
return devicesDict;
}
export enum OwnDevicesError {
Unsupported = "Unsupported",
Default = "Default",
}
export type DevicesState = {
devices: DevicesDictionary;
dehydratedDeviceId?: string;
pushers: IPusher[];
localNotificationSettings: Map<string, LocalNotificationSettings>;
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: ExtendedDevice["device_id"]) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: ExtendedDevice["device_id"], deviceName: string) => Promise<void>;
setPushNotifications: (deviceId: ExtendedDevice["device_id"], enabled: boolean) => Promise<void>;
error?: OwnDevicesError;
supportsMSC3881?: boolean | undefined;
};
export const useOwnDevices = (): DevicesState => {
const sdkContext = useContext(SDKContext);
const matrixClient = sdkContext.client!;
const currentDeviceId = matrixClient.getDeviceId()!;
const userId = matrixClient.getSafeUserId();
const [devices, setDevices] = useState<DevicesState["devices"]>({});
const [dehydratedDeviceId, setDehydratedDeviceId] = useState<DevicesState["dehydratedDeviceId"]>(undefined);
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
const [localNotificationSettings, setLocalNotificationSettings] = useState<
DevicesState["localNotificationSettings"]
>(new Map<string, LocalNotificationSettings>());
const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true);
const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes!
const [error, setError] = useState<OwnDevicesError>();
useEffect(() => {
matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then((hasSupport) => {
setSupportsMSC3881(hasSupport);
});
}, [matrixClient]);
const refreshDevices = useCallback(async (): Promise<void> => {
setIsLoadingDeviceList(true);
try {
const devices = await fetchExtendedDeviceInformation(matrixClient);
setDevices(devices);
const { pushers } = await matrixClient.getPushers();
setPushers(pushers);
const notificationSettings = new Map<string, LocalNotificationSettings>();
Object.keys(devices).forEach((deviceId) => {
const eventType = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
const event = matrixClient.getAccountData(eventType);
if (event) {
notificationSettings.set(deviceId, event.getContent());
}
});
setLocalNotificationSettings(notificationSettings);
const ownUserId = matrixClient.getUserId()!;
const userDevices = (await matrixClient.getCrypto()?.getUserDeviceInfo([ownUserId]))?.get(ownUserId);
const dehydratedDeviceIds: string[] = [];
for (const device of userDevices?.values() ?? []) {
if (device.dehydrated) {
dehydratedDeviceIds.push(device.deviceId);
}
}
// If the user has exactly one device marked as dehydrated, we consider
// that as the dehydrated device, and hide it as a normal device (but
// indicate that the user is using a dehydrated device). If the user has
// more than one, that is anomalous, and we show all the devices so that
// nothing is hidden.
setDehydratedDeviceId(dehydratedDeviceIds.length == 1 ? dehydratedDeviceIds[0] : undefined);
setIsLoadingDeviceList(false);
} catch (error) {
if ((error as MatrixError).httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API.
setError(OwnDevicesError.Unsupported);
} else {
logger.error("Error loading sessions:", error);
setError(OwnDevicesError.Default);
}
setIsLoadingDeviceList(false);
}
}, [matrixClient]);
useEffect(() => {
refreshDevices();
}, [refreshDevices]);
useEffect(() => {
const deviceIds = Object.keys(devices);
// empty devices means devices have not been fetched yet
// as there is always at least the current device
if (deviceIds.length) {
pruneClientInformation(deviceIds, matrixClient);
}
}, [devices, matrixClient]);
useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => {
if (users.includes(userId)) {
refreshDevices();
}
});
useEventEmitter(matrixClient, ClientEvent.AccountData, (event: MatrixEvent): void => {
const type = event.getType();
if (type.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
const newSettings = new Map(localNotificationSettings);
const deviceId = type.slice(type.lastIndexOf(".") + 1);
newSettings.set(deviceId, event.getContent<LocalNotificationSettings>());
setLocalNotificationSettings(newSettings);
}
});
const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified;
const requestDeviceVerification =
isCurrentDeviceVerified && userId
? async (deviceId: ExtendedDevice["device_id"]): Promise<VerificationRequest> => {
return await matrixClient.getCrypto()!.requestDeviceVerification(userId, deviceId);
}
: undefined;
const saveDeviceName = useCallback(
async (deviceId: ExtendedDevice["device_id"], deviceName: string): Promise<void> => {
const device = devices[deviceId];
// no change
if (deviceName === device?.display_name) {
return;
}
try {
await matrixClient.setDeviceDetails(deviceId, { display_name: deviceName });
await refreshDevices();
} catch (error) {
logger.error("Error setting device name", error);
throw new Error(_t("settings|sessions|error_set_name"));
}
},
[matrixClient, devices, refreshDevices],
);
const setPushNotifications = useCallback(
async (deviceId: ExtendedDevice["device_id"], enabled: boolean): Promise<void> => {
try {
const pusher = pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === deviceId);
if (pusher) {
await matrixClient.setPusher({
...pusher,
[PUSHER_ENABLED.name]: enabled,
});
} else if (localNotificationSettings.has(deviceId)) {
await matrixClient.setLocalNotificationSettings(deviceId, {
is_silenced: !enabled,
});
}
} catch (error) {
logger.error("Error setting pusher state", error);
throw new Error(_t("settings|sessions|error_pusher_state"));
} finally {
await refreshDevices();
}
},
[matrixClient, pushers, localNotificationSettings, refreshDevices],
);
return {
devices,
dehydratedDeviceId,
pushers,
localNotificationSettings,
currentDeviceId,
isLoadingDeviceList,
error,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPushNotifications,
supportsMSC3881,
};
};

View file

@ -0,0 +1,200 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback, useEffect, useState } from "react";
import { SERVICE_TYPES, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Alert } from "@vector-im/compound-web";
import { getThreepidsWithBindStatus } from "../../../../boundThreepids";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../../AddThreepid";
import SettingsStore from "../../../../settings/SettingsStore";
import { UIFeature } from "../../../../settings/UIFeature";
import { _t } from "../../../../languageHandler";
import SetIdServer from "../SetIdServer";
import SettingsSubsection from "../shared/SettingsSubsection";
import InlineTermsAgreement from "../../terms/InlineTermsAgreement";
import { Service, ServicePolicyPair, startTermsFlow } from "../../../../Terms";
import IdentityAuthClient from "../../../../IdentityAuthClient";
import { abbreviateUrl } from "../../../../utils/UrlUtils";
import { useDispatcher } from "../../../../hooks/useDispatcher";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../../dispatcher/payloads";
import { AddRemoveThreepids } from "../AddRemoveThreepids";
type RequiredPolicyInfo =
| {
// This object is passed along to a component for handling
policiesAndServices: null; // From the startTermsFlow callback
agreedUrls: null; // From the startTermsFlow callback
resolve: null; // Promise resolve function for startTermsFlow callback
}
| {
policiesAndServices: ServicePolicyPair[];
agreedUrls: string[];
resolve: (values: string[]) => void;
};
/**
* Settings controlling how a user's email addreses and phone numbers can be used to discover them
*/
export const DiscoverySettings: React.FC = () => {
const client = useMatrixClientContext();
const [isLoadingThreepids, setIsLoadingThreepids] = useState<boolean>(true);
const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]);
const [phoneNumbers, setPhoneNumbers] = useState<ThirdPartyIdentifier[]>([]);
const [idServerName, setIdServerName] = useState<string | undefined>(abbreviateUrl(client.getIdentityServerUrl()));
const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false);
const [requiredPolicyInfo, setRequiredPolicyInfo] = useState<RequiredPolicyInfo>({
// This object is passed along to a component for handling
policiesAndServices: null, // From the startTermsFlow callback
agreedUrls: null, // From the startTermsFlow callback
resolve: null, // Promise resolve function for startTermsFlow callback
});
const [hasTerms, setHasTerms] = useState<boolean>(false);
const getThreepidState = useCallback(async () => {
setIsLoadingThreepids(true);
const threepids = await getThreepidsWithBindStatus(client);
setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setIsLoadingThreepids(false);
}, [client]);
useDispatcher(
defaultDispatcher,
useCallback(
(payload: ActionPayload) => {
if (payload.action === "id_server_changed") {
setIdServerName(abbreviateUrl(client.getIdentityServerUrl()));
getThreepidState().then();
}
},
[client, getThreepidState],
),
);
useEffect(() => {
(async () => {
try {
await getThreepidState();
const capabilities = await client.getCapabilities();
setCanMake3pidChanges(
!capabilities["m.3pid_changes"] || capabilities["m.3pid_changes"].enabled === true,
);
// By starting the terms flow we get the logic for checking which terms the user has signed
// for free. So we might as well use that for our own purposes.
const idServerUrl = client.getIdentityServerUrl();
if (!idServerUrl) {
return;
}
const authClient = new IdentityAuthClient();
try {
const idAccessToken = await authClient.getAccessToken({ check: false });
await startTermsFlow(
client,
[new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken!)],
(policiesAndServices, agreedUrls, extraClassNames) => {
return new Promise((resolve) => {
setIdServerName(abbreviateUrl(idServerUrl));
setHasTerms(true);
setRequiredPolicyInfo({
policiesAndServices,
agreedUrls,
resolve,
});
});
},
);
// User accepted all terms
setHasTerms(false);
} catch (e) {
logger.warn(
`Unable to reach identity server at ${idServerUrl} to check ` + `for terms in Settings`,
);
logger.warn(e);
}
} catch (e) {}
})();
}, [client, getThreepidState]);
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
if (hasTerms && requiredPolicyInfo.policiesAndServices) {
const intro = (
<Alert type="info" title={_t("settings|general|discovery_needs_terms_title")}>
{_t("settings|general|discovery_needs_terms", { serverName: idServerName })}
</Alert>
);
return (
<>
<InlineTermsAgreement
policiesAndServicePairs={requiredPolicyInfo.policiesAndServices}
agreedUrls={requiredPolicyInfo.agreedUrls}
onFinished={requiredPolicyInfo.resolve}
introElement={intro}
/>
{/* has its own heading as it includes the current identity server */}
<SetIdServer missingTerms={true} />
</>
);
}
let threepidSection;
if (idServerName) {
threepidSection = (
<>
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
description={emails.length === 0 ? _t("settings|general|discovery_email_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={emails}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|general|msisdns_heading")}
description={phoneNumbers.length === 0 ? _t("settings|general|discovery_msisdn_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
</>
);
}
return (
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection" stretchContent>
{threepidSection}
{/* has its own heading as it includes the current identity server */}
<SetIdServer missingTerms={false} />
</SettingsSubsection>
);
};
export default DiscoverySettings;

View file

@ -0,0 +1,124 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { ThreepidMedium, IPusher } from "matrix-js-sdk/src/matrix";
import React, { useCallback, useMemo } from "react";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { Action } from "../../../../dispatcher/actions";
import dispatcher from "../../../../dispatcher/dispatcher";
import { usePushers } from "../../../../hooks/usePushers";
import { useThreepids } from "../../../../hooks/useThreepids";
import { _t } from "../../../../languageHandler";
import SdkConfig from "../../../../SdkConfig";
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";
function generalTabButton(content: string): JSX.Element {
return (
<AccessibleButton
kind="link_inline"
onClick={() => {
dispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Account,
});
}}
>
{content}
</AccessibleButton>
);
}
export function NotificationPusherSettings(): JSX.Element {
const EmailPusherTemplate: Omit<IPusher, "pushkey" | "device_display_name" | "append"> = useMemo(
() => ({
kind: "email",
app_id: "m.email",
app_display_name: _t("notifications|email_pusher_app_display_name"),
lang: navigator.language,
data: {
brand: SdkConfig.get().brand,
},
}),
[],
);
const cli = useMatrixClientContext();
const [pushers, refreshPushers] = usePushers(cli);
const [threepids, refreshThreepids] = useThreepids(cli);
const setEmailEnabled = useCallback(
(email: string, enabled: boolean) => {
if (enabled) {
cli.setPusher({
...EmailPusherTemplate,
pushkey: email,
device_display_name: email,
// We always append for email pushers since we don't want to stop other
// accounts notifying to the same email address
append: true,
}).catch((err) => console.error(err));
} else {
const pusher = pushers.find((p) => p.kind === "email" && p.pushkey === email);
if (pusher) {
cli.removePusher(pusher.pushkey, pusher.app_id).catch((err) => console.error(err));
}
}
refreshThreepids();
refreshPushers();
},
[EmailPusherTemplate, cli, pushers, refreshPushers, refreshThreepids],
);
const notificationTargets = pushers.filter((it) => it.kind !== "email");
return (
<>
<SettingsSubsection
className="mx_NotificationPusherSettings"
heading={_t("settings|notifications|email_section")}
>
<SettingsSubsectionText className="mx_NotificationPusherSettings_description">
{_t("settings|notifications|email_description")}
</SettingsSubsectionText>
<div className="mx_SettingsSubsection_description mx_NotificationPusherSettings_detail">
<SettingsSubsectionText>
{_t("settings|notifications|email_select", {}, { button: generalTabButton })}
</SettingsSubsectionText>
</div>
<SettingsIndent>
{threepids
.filter((t) => t.medium === ThreepidMedium.Email)
.map((email) => (
<LabelledCheckbox
key={email.address}
label={email.address}
value={pushers.find((it) => it.pushkey === email.address) !== undefined}
onChange={(value) => setEmailEnabled(email.address, value)}
/>
))}
</SettingsIndent>
</SettingsSubsection>
{notificationTargets.length > 0 && (
<SettingsSubsection heading={_t("settings|notifications|push_targets")}>
<ul>
{pushers
.filter((it) => it.kind !== "email")
.map((pusher) => (
<li key={pusher.pushkey}>{pusher.device_display_name || pusher.app_display_name}</li>
))}
</ul>
</SettingsSubsection>
)}
</>
);
}

View file

@ -0,0 +1,383 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import NewAndImprovedIcon from "../../../../../res/img/element-icons/new-and-improved.svg";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useNotificationSettings } from "../../../../hooks/useNotificationSettings";
import { useSettingValue } from "../../../../hooks/useSettings";
import { _t } from "../../../../languageHandler";
import {
DefaultNotificationSettings,
NotificationSettings,
} from "../../../../models/notificationsettings/NotificationSettings";
import { RoomNotifState } from "../../../../RoomNotifs";
import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { clearAllNotifications } from "../../../../utils/notifications";
import AccessibleButton from "../../elements/AccessibleButton";
import ExternalLink from "../../elements/ExternalLink";
import LabelledCheckbox from "../../elements/LabelledCheckbox";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import StyledRadioGroup from "../../elements/StyledRadioGroup";
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 { NotificationPusherSettings } from "./NotificationPusherSettings";
import SettingsFlag from "../../elements/SettingsFlag";
enum NotificationDefaultLevels {
AllMessages = "all_messages",
PeopleMentionsKeywords = "people_mentions_keywords",
MentionsKeywords = "mentions_keywords",
}
function toDefaultLevels(levels: NotificationSettings["defaultLevels"]): NotificationDefaultLevels {
if (levels.room === RoomNotifState.AllMessages) {
return NotificationDefaultLevels.AllMessages;
} else if (levels.dm === RoomNotifState.AllMessages) {
return NotificationDefaultLevels.PeopleMentionsKeywords;
} else {
return NotificationDefaultLevels.MentionsKeywords;
}
}
function boldText(text: string): JSX.Element {
return <strong>{text}</strong>;
}
function helpLink(sub: string): JSX.Element {
return <ExternalLink href="https://element.io/help#settings2">{sub}</ExternalLink>;
}
function useHasUnreadNotifications(): boolean {
const cli = useMatrixClientContext();
return cli.getRooms().some((room) => room.getUnreadNotificationCount() > 0);
}
/**
* The new notification settings tab view, only displayed if the user has Features.NotificationSettings2 enabled
*/
export default function NotificationSettings2(): JSX.Element {
const cli = useMatrixClientContext();
const desktopNotifications = useSettingValue<boolean>("notificationsEnabled");
const desktopShowBody = useSettingValue<boolean>("notificationBodyEnabled");
const audioNotifications = useSettingValue<boolean>("audioNotificationsEnabled");
const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli);
const disabled = model === null || hasPendingChanges;
const settings = model ?? DefaultNotificationSettings;
const [updatingUnread, setUpdatingUnread] = useState<boolean>(false);
const hasUnreadNotifications = useHasUnreadNotifications();
const NotificationOptions = [
{
value: NotificationDefaultLevels.AllMessages,
label: _t("notifications|all_messages"),
},
{
value: NotificationDefaultLevels.PeopleMentionsKeywords,
label: _t("settings|notifications|people_mentions_keywords"),
},
{
value: NotificationDefaultLevels.MentionsKeywords,
label: _t("settings|notifications|mentions_keywords_only"),
},
];
return (
<div className="mx_NotificationSettings2">
{hasPendingChanges && model !== null && (
<SettingsBanner
icon={<img src={NewAndImprovedIcon} alt="" width={12} />}
action={_t("action|proceed")}
onAction={() => reconcile(model!)}
>
{_t(
"settings|notifications|labs_notice_prompt",
{},
{
strong: boldText,
a: helpLink,
},
)}
</SettingsBanner>
)}
<SettingsSection>
<div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags">
<LabelledToggleSwitch
label={_t("settings|notifications|enable_notifications_account")}
value={!settings.globalMute}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
globalMute: !value,
});
}}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|enable_desktop_notifications_session")}
value={desktopNotifications}
onChange={(value) =>
SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|desktop_notification_message_preview")}
value={desktopShowBody}
onChange={(value) =>
SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("settings|notifications|enable_audible_notifications_session")}
value={audioNotifications}
onChange={(value) =>
SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
</div>
<SettingsSubsection
heading={_t("settings|notifications|default_setting_section")}
description={_t("settings|notifications|default_setting_description")}
>
<StyledRadioGroup
name="defaultNotificationLevel"
value={toDefaultLevels(settings.defaultLevels)}
disabled={disabled}
definitions={NotificationOptions}
onChange={(value) => {
reconcile({
...model!,
defaultLevels: {
...model!.defaultLevels,
dm:
value !== NotificationDefaultLevels.MentionsKeywords
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
room:
value === NotificationDefaultLevels.AllMessages
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|play_sound_for_section")}
description={_t("settings|notifications|play_sound_for_description")}
>
<LabelledCheckbox
label="People"
value={settings.sound.people !== undefined}
disabled={disabled || settings.defaultLevels.dm === RoomNotifState.MentionsOnly}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
people: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|mentions_keywords")}
value={settings.sound.mentions !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
mentions: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|voip")}
value={settings.sound.calls !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
calls: value ? "ring" : undefined,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|notifications|other_section")}>
<LabelledCheckbox
label={_t("settings|notifications|invites")}
value={settings.activity.invite}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
invite: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|room_activity")}
value={settings.activity.status_event}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
status_event: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notices")}
value={settings.activity.bot_notices}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
bot_notices: value,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|notifications|mentions_keywords")}
description={_t(
"settings|notifications|keywords",
{},
{
badge: (
<StatelessNotificationBadge
symbol="1"
count={1}
level={NotificationLevel.Notification}
/>
),
},
)}
>
<LabelledCheckbox
label={_t("settings|notifications|notify_at_room")}
value={settings.mentions.room}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
room: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_mention", {
mxid: cli.getUserId()!,
})}
value={settings.mentions.user}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
user: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("settings|notifications|notify_keyword")}
byline={_t("settings|notifications|keywords_prompt")}
value={settings.mentions.keywords}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
keywords: value,
},
});
}}
/>
<TagComposer
id="mx_NotificationSettings2_Keywords"
tags={model?.keywords ?? []}
disabled={disabled}
onAdd={(keyword) => {
reconcile({
...model!,
keywords: [keyword, ...model!.keywords],
});
}}
onRemove={(keyword) => {
reconcile({
...model!,
keywords: model!.keywords.filter((it) => it !== keyword),
});
}}
label={_t("notifications|keyword")}
placeholder={_t("notifications|keyword_new")}
/>
<SettingsFlag name="Notifications.showbold" level={SettingLevel.DEVICE} />
<SettingsFlag name="Notifications.tac_only_notifications" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<NotificationPusherSettings />
<SettingsSubsection heading={_t("settings|notifications|quick_actions_section")}>
{hasUnreadNotifications && (
<AccessibleButton
kind="primary_outline"
disabled={updatingUnread}
onClick={async () => {
setUpdatingUnread(true);
await clearAllNotifications(cli);
setUpdatingUnread(false);
}}
>
{_t("settings|notifications|quick_actions_mark_all_read")}
</AccessibleButton>
)}
<AccessibleButton
kind="danger_outline"
disabled={model === null}
onClick={() => {
reconcile(DefaultNotificationSettings);
}}
>
{_t("settings|notifications|quick_actions_reset")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection>
</div>
);
}

View file

@ -0,0 +1,31 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { PropsWithChildren, ReactNode } from "react";
import AccessibleButton from "../../elements/AccessibleButton";
interface Props {
icon?: ReactNode;
action?: ReactNode;
onAction?: () => void;
}
export function SettingsBanner({ children, icon, action, onAction }: PropsWithChildren<Props>): JSX.Element {
return (
<div className="mx_SettingsBanner">
{icon}
<div className="mx_SettingsBanner_content">{children}</div>
{action && (
<AccessibleButton kind="primary_outline" onClick={onAction ?? null}>
{action}
</AccessibleButton>
)}
</div>
);
}

View file

@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { HTMLAttributes } from "react";
export interface SettingsIndentProps extends HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
export const SettingsIndent: React.FC<SettingsIndentProps> = ({ children, ...rest }) => (
<div {...rest} className="mx_SettingsIndent">
{children}
</div>
);

View file

@ -0,0 +1,56 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classnames from "classnames";
import React, { HTMLAttributes } from "react";
import Heading from "../../typography/Heading";
export interface SettingsSectionProps extends HTMLAttributes<HTMLDivElement> {
heading?: string | React.ReactNode;
children?: React.ReactNode;
}
function renderHeading(heading: string | React.ReactNode | undefined): React.ReactNode | undefined {
switch (typeof heading) {
case "string":
return (
<Heading as="h2" size="3">
{heading}
</Heading>
);
case "undefined":
return undefined;
default:
return heading;
}
}
/**
* A section of settings content
* A SettingsTab may contain one or more SettingsSections
* Eg:
* ```
* <SettingsTab>
* <SettingsSection heading="General">
* <SettingsSubsection heading="Profile">
* // profile settings form
* <SettingsSubsection>
* <SettingsSubsection heading="Account">
* // account settings
* <SettingsSubsection>
* </SettingsSection>
* </SettingsTab>
* ```
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({ className, heading, children, ...rest }) => (
<div {...rest} className={classnames("mx_SettingsSection", className)}>
{renderHeading(heading)}
<div className="mx_SettingsSection_subSections">{children}</div>
</div>
);

View file

@ -0,0 +1,69 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { HTMLAttributes } from "react";
import { Separator } from "@vector-im/compound-web";
import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement> {
heading?: string | React.ReactNode;
description?: string | React.ReactNode;
children?: React.ReactNode;
// when true content will be justify-items: stretch, which will make items within the section stretch to full width.
stretchContent?: boolean;
/*
* When true, the legacy UI style will be applied to the subsection.
* @default true
*/
legacy?: boolean;
}
export const SettingsSubsectionText: React.FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...rest }) => (
<div {...rest} className="mx_SettingsSubsection_text">
{children}
</div>
);
export const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({
heading,
description,
children,
stretchContent,
legacy = true,
...rest
}) => (
<div
{...rest}
className={classNames("mx_SettingsSubsection", {
mx_SettingsSubsection_newUi: !legacy,
})}
>
{typeof heading === "string" ? <SettingsSubsectionHeading heading={heading} legacy={legacy} /> : <>{heading}</>}
{!!description && (
<div className="mx_SettingsSubsection_description">
<SettingsSubsectionText>{description}</SettingsSubsectionText>
</div>
)}
{!!children && (
<div
className={classNames("mx_SettingsSubsection_content", {
mx_SettingsSubsection_contentStretch: !!stretchContent,
mx_SettingsSubsection_noHeading: !heading && !description,
mx_SettingsSubsection_content_newUi: !legacy,
})}
>
{children}
</div>
)}
{!legacy && <Separator />}
</div>
);
export default SettingsSubsection;

View file

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { HTMLAttributes } from "react";
import Heading from "../../typography/Heading";
export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivElement> {
heading: string;
legacy?: boolean;
children?: React.ReactNode;
}
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({
heading,
legacy = true,
children,
...rest
}) => {
const size = legacy ? "4" : "3";
return (
<div {...rest} className="mx_SettingsSubsectionHeading">
<Heading className="mx_SettingsSubsectionHeading_heading" size={size} as="h3">
{heading}
</Heading>
{children}
</div>
);
};

View file

@ -0,0 +1,38 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { HTMLAttributes } from "react";
export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "className"> {
children?: React.ReactNode;
}
/**
* Container for a tab of settings panel content
* Should contain one or more SettingsSection
* Settings width, padding and spacing between sections
* Eg:
* ```
* <SettingsTab>
* <SettingsSection heading="General">
* <SettingsSubsection heading="Profile">
* // profile settings form
* <SettingsSubsection>
* <SettingsSubsection heading="Account">
* // account settings
* <SettingsSubsection>
* </SettingsSection>
* </SettingsTab>
* ```
*/
const SettingsTab: React.FC<SettingsTabProps> = ({ children, ...rest }) => (
<div {...rest} className="mx_SettingsTab">
<div className="mx_SettingsTab_sections">{children}</div>
</div>
);
export default SettingsTab;

View file

@ -0,0 +1,178 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
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 { EventType, Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog";
import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import CopyableText from "../../../elements/CopyableText";
import { ViewRoomPayload } from "../../../../../dispatcher/payloads/ViewRoomPayload";
import SettingsStore from "../../../../../settings/SettingsStore";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
interface IProps {
room: Room;
closeSettingsFn(): void;
}
interface IRecommendedVersion {
version: string;
needsUpgrade: boolean;
urgent: boolean;
}
interface IState {
// This is eventually set to the value of room.getRecommendedVersion()
upgradeRecommendation?: IRecommendedVersion;
/** The room ID of this room's predecessor, if it exists. */
oldRoomId?: string;
/** The ID of tombstone event in this room's predecessor, if it exists. */
oldEventId?: string;
/** The via servers to use to find this room's predecessor, if it exists. */
oldViaServers?: string[];
upgraded?: boolean;
}
export default class AdvancedRoomSettingsTab extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
this.state = {};
// we handle lack of this object gracefully later, so don't worry about it failing here.
const room = this.props.room;
room.getRecommendedVersion().then((v) => {
const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, "");
const additionalStateChanges: Partial<IState> = {};
const predecessor = room.findPredecessor(msc3946ProcessDynamicPredecessor);
if (predecessor) {
additionalStateChanges.oldRoomId = predecessor.roomId;
additionalStateChanges.oldEventId = predecessor.eventId;
additionalStateChanges.oldViaServers = predecessor.viaServers;
}
this.setState({
upgraded: !!tombstone?.getContent().replacement_room,
upgradeRecommendation: v,
...additionalStateChanges,
});
});
}
private upgradeRoom = (): void => {
Modal.createDialog(RoomUpgradeDialog, { room: this.props.room });
};
private onOldRoomClicked = (e: ButtonEvent): void => {
e.preventDefault();
e.stopPropagation();
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.state.oldRoomId,
event_id: this.state.oldEventId,
via_servers: this.state.oldViaServers,
metricsTrigger: "WebPredecessorSettings",
metricsViaKeyboard: e.type !== "click",
});
this.props.closeSettingsFn();
};
public render(): React.ReactNode {
const room = this.props.room;
const isSpace = room.isSpaceRoom();
let unfederatableSection: JSX.Element | undefined;
if (room.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent()["m.federate"] === false) {
unfederatableSection = <div>{_t("room_settings|advanced|unfederated")}</div>;
}
let roomUpgradeButton;
if (this.state.upgradeRecommendation && this.state.upgradeRecommendation.needsUpgrade && !this.state.upgraded) {
roomUpgradeButton = (
<div>
<p className="mx_SettingsTab_warningText">
{_t(
"room_settings|advanced|room_upgrade_warning",
{},
{
b: (sub) => <strong>{sub}</strong>,
i: (sub) => <i>{sub}</i>,
},
)}
</p>
<AccessibleButton onClick={this.upgradeRoom} kind="primary">
{isSpace
? _t("room_settings|advanced|space_upgrade_button")
: _t("room_settings|advanced|room_upgrade_button")}
</AccessibleButton>
</div>
);
}
let oldRoomLink: JSX.Element | undefined;
if (this.state.oldRoomId) {
let copy: string;
if (isSpace) {
copy = _t("room_settings|advanced|space_predecessor", { spaceName: room.name ?? this.state.oldRoomId });
} else {
copy = _t("room_settings|advanced|room_predecessor", { roomName: room.name ?? this.state.oldRoomId });
}
oldRoomLink = (
<AccessibleButton element="a" onClick={this.onOldRoomClicked}>
{copy}
</AccessibleButton>
);
}
return (
<SettingsTab>
<SettingsSection heading={_t("common|advanced")}>
<SettingsSubsection
heading={
room.isSpaceRoom()
? _t("room_settings|advanced|information_section_space")
: _t("room_settings|advanced|information_section_room")
}
>
<div>
<span>{_t("room_settings|advanced|room_id")}</span>
<CopyableText getTextToCopy={() => this.props.room.roomId}>
{this.props.room.roomId}
</CopyableText>
</div>
{unfederatableSection}
</SettingsSubsection>
<SettingsSubsection heading={_t("room_settings|advanced|room_version_section")}>
<div>
<span>{_t("room_settings|advanced|room_version")}</span>&nbsp;
{room.getVersion()}
</div>
{oldRoomLink}
{roomUpgradeButton}
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,102 @@
/*
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
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import { Room, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../../languageHandler";
import BridgeTile from "../../BridgeTile";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
const BRIDGE_EVENT_TYPES = [
"uk.half-shot.bridge",
// m.bridge
];
const BRIDGES_LINK = "https://matrix.org/bridges/";
interface IProps {
room: Room;
}
export default class BridgeSettingsTab extends React.Component<IProps> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
private renderBridgeCard(event: MatrixEvent, room: Room | null): ReactNode {
const content = event.getContent();
if (!room || !content?.channel || !content.protocol) return null;
return <BridgeTile key={event.getId()} room={room} ev={event} />;
}
public static getBridgeStateEvents(client: MatrixClient, roomId: string): MatrixEvent[] {
const roomState = client.getRoom(roomId)?.currentState;
if (!roomState) return [];
return BRIDGE_EVENT_TYPES.map((typeName) => roomState.getStateEvents(typeName)).flat(1);
}
public render(): React.ReactNode {
// This settings tab will only be invoked if the following function returns more
// than 0 events, so no validation is needed at this stage.
const bridgeEvents = BridgeSettingsTab.getBridgeStateEvents(this.context, this.props.room.roomId);
const room = this.props.room;
let content: JSX.Element;
if (bridgeEvents.length > 0) {
content = (
<div>
<p>
{_t(
"room_settings|bridges|description",
{},
{
// TODO: We don't have this link yet: this will prevent the translators
// having to re-translate the string when we do.
a: (sub) => (
<a href={BRIDGES_LINK} target="_blank" rel="noreferrer noopener">
{sub}
</a>
),
},
)}
</p>
<ul className="mx_RoomSettingsDialog_BridgeList">
{bridgeEvents.map((event) => this.renderBridgeCard(event, room))}
</ul>
</div>
);
} else {
content = (
<p>
{_t(
"room_settings|bridges|empty",
{},
{
// TODO: We don't have this link yet: this will prevent the translators
// having to re-translate the string when we do.
a: (sub) => (
<a href={BRIDGES_LINK} target="_blank" rel="noreferrer noopener">
{sub}
</a>
),
},
)}
</p>
);
}
return (
<SettingsTab>
<SettingsSection heading={_t("room_settings|bridges|title")}>{content}</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,100 @@
/*
Copyright 2019-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, { ContextType } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { _t } from "../../../../../languageHandler";
import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
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 SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
interface IProps {
room: Room;
}
interface IState {
isRoomPublished: boolean;
}
export default class GeneralRoomSettingsTab extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
public declare context: ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
isRoomPublished: false, // loaded async
};
}
private onLeaveClick = (ev: ButtonEvent): void => {
dis.dispatch({
action: "leave_room",
room_id: this.props.room.roomId,
});
PosthogTrackers.trackInteraction("WebRoomSettingsLeaveButton", ev);
};
public render(): React.ReactNode {
const client = this.context;
const room = this.props.room;
const canSetAliases = true; // Previously, we arbitrarily only allowed admins to do this
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", "") ?? undefined;
const urlPreviewSettings = SettingsStore.getValue(UIFeature.URLPreviews) ? (
<UrlPreviewSettings room={room} />
) : null;
let leaveSection;
if (room.getMyMembership() === KnownMembership.Join) {
leaveSection = (
<SettingsSubsection heading={_t("action|leave_room")}>
<AccessibleButton kind="danger" onClick={this.onLeaveClick}>
{_t("action|leave_room")}
</AccessibleButton>
</SettingsSubsection>
);
}
return (
<SettingsTab data-testid="General">
<SettingsSection heading={_t("common|general")}>
<RoomProfileSettings roomId={room.roomId} />
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|aliases_section")}>
<AliasSettings
roomId={room.roomId}
canSetCanonicalAlias={canSetCanonical}
canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv}
/>
</SettingsSection>
<SettingsSection heading={_t("room_settings|general|other_section")}>
{urlPreviewSettings}
{leaveSection}
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,309 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
import Notifier from "../../../../../Notifier";
import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { RoomEchoChamber } from "../../../../../stores/local-echo/RoomEchoChamber";
import { EchoChamber } from "../../../../../stores/local-echo/EchoChamber";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import { RoomNotifState } from "../../../../../RoomNotifs";
import defaultDispatcher from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { UserTab } from "../../../dialogs/UserTab";
import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
interface IProps {
roomId: string;
closeSettingsFn(): void;
}
interface IState {
currentSound: string;
uploadedFile: File | null;
}
export default class NotificationsSettingsTab extends React.Component<IProps, IState> {
private readonly roomProps: RoomEchoChamber;
private soundUpload = createRef<HTMLInputElement>();
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!);
let currentSound = "default";
const soundData = Notifier.getSoundForRoom(this.props.roomId);
if (soundData) {
currentSound = soundData.name || soundData.url;
}
this.state = {
currentSound,
uploadedFile: null,
};
}
private triggerUploader = async (e: ButtonEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
this.soundUpload.current?.click();
};
private onSoundUploadChanged = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files || !e.target.files.length) {
this.setState({
uploadedFile: null,
});
return;
}
const file = e.target.files[0];
this.setState({
uploadedFile: file,
});
};
private onClickSaveSound = async (e: ButtonEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
try {
await this.saveSound();
} catch (ex) {
logger.error(`Unable to save notification sound for ${this.props.roomId}`);
logger.error(ex);
}
};
private async saveSound(): Promise<void> {
if (!this.state.uploadedFile) {
return;
}
let type = this.state.uploadedFile.type;
if (type === "video/ogg") {
// XXX: I've observed browsers allowing users to pick a audio/ogg files,
// and then calling it a video/ogg. This is a lame hack, but man browsers
// suck at detecting mimetypes.
type = "audio/ogg";
}
const { content_uri: url } = await this.context.uploadContent(this.state.uploadedFile, {
type,
});
await SettingsStore.setValue("notificationSound", this.props.roomId, SettingLevel.ROOM_ACCOUNT, {
name: this.state.uploadedFile.name,
type: type,
size: this.state.uploadedFile.size,
url,
});
this.setState({
uploadedFile: null,
currentSound: this.state.uploadedFile.name,
});
}
private clearSound = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
SettingsStore.setValue("notificationSound", this.props.roomId, SettingLevel.ROOM_ACCOUNT, null);
this.setState({
currentSound: "default",
});
};
private onRoomNotificationChange = (value: RoomNotifState): void => {
this.roomProps.notificationVolume = value;
this.forceUpdate();
};
private onOpenSettingsClick = (event: ButtonEvent): void => {
// avoid selecting the radio button
event.preventDefault();
this.props.closeSettingsFn();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Notifications,
});
};
public render(): React.ReactNode {
let currentUploadedFile: JSX.Element | undefined;
if (this.state.uploadedFile) {
currentUploadedFile = (
<div>
<span>
{_t("room_settings|notifications|uploaded_sound")}: <code>{this.state.uploadedFile.name}</code>
</span>
</div>
);
}
return (
<SettingsTab>
<SettingsSection heading={_t("notifications|enable_prompt_toast_title")}>
<div className="mx_NotificationSettingsTab_notificationsSection">
<StyledRadioGroup
name="roomNotificationSetting"
definitions={[
{
value: RoomNotifState.AllMessages,
className: "mx_NotificationSettingsTab_defaultEntry",
label: (
<>
{_t("notifications|default")}
<div className="mx_NotificationSettingsTab_microCopy">
{_t(
"room_settings|notifications|settings_link",
{},
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={this.onOpenSettingsClick}
>
{sub}
</AccessibleButton>
),
},
)}
</div>
</>
),
},
{
value: RoomNotifState.AllMessagesLoud,
className: "mx_NotificationSettingsTab_allMessagesEntry",
label: (
<>
{_t("notifications|all_messages")}
<div className="mx_NotificationSettingsTab_microCopy">
{_t("notifications|all_messages_description")}
</div>
</>
),
},
{
value: RoomNotifState.MentionsOnly,
className: "mx_NotificationSettingsTab_mentionsKeywordsEntry",
label: (
<>
{_t("notifications|mentions_and_keywords")}
<div className="mx_NotificationSettingsTab_microCopy">
{_t(
"notifications|mentions_and_keywords_description",
{},
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={this.onOpenSettingsClick}
>
{sub}
</AccessibleButton>
),
},
)}
</div>
</>
),
},
{
value: RoomNotifState.Mute,
className: "mx_NotificationSettingsTab_noneEntry",
label: (
<>
{_t("common|off")}
<div className="mx_NotificationSettingsTab_microCopy">
{_t("notifications|mute_description")}
</div>
</>
),
},
]}
onChange={this.onRoomNotificationChange}
value={this.roomProps.notificationVolume}
/>
</div>
<SettingsSubsection heading={_t("room_settings|notifications|sounds_section")}>
<div>
<div className="mx_SettingsTab_subsectionText">
<span>
{_t("room_settings|notifications|notification_sound")}:{" "}
<code>{this.state.currentSound}</code>
</span>
</div>
<AccessibleButton
className="mx_NotificationSound_resetSound"
disabled={this.state.currentSound == "default"}
onClick={this.clearSound}
kind="primary"
>
{_t("action|reset")}
</AccessibleButton>
</div>
<div>
<h4 className="mx_Heading_h4">{_t("room_settings|notifications|custom_sound_prompt")}</h4>
<div className="mx_SettingsFlag">
<form autoComplete="off" noValidate={true}>
<input
ref={this.soundUpload}
className="mx_NotificationSound_soundUpload"
type="file"
onClick={chromeFileInputFix}
onChange={this.onSoundUploadChanged}
accept="audio/*"
aria-label={_t("room_settings|notifications|upload_sound_label")}
/>
</form>
{currentUploadedFile}
</div>
<AccessibleButton
className="mx_NotificationSound_browse"
onClick={this.triggerUploader}
kind="primary"
>
{_t("room_settings|notifications|browse_button")}
</AccessibleButton>
<AccessibleButton
className="mx_NotificationSound_save"
disabled={this.state.uploadedFile == null}
onClick={this.onClickSaveSound}
kind="primary"
>
{_t("action|save")}
</AccessibleButton>
<br />
</div>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,166 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Nordeck IT + Consulting GmbH
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventTimeline, MatrixError, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import React, { useCallback, useState, VFC } from "react";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import { Icon as CheckIcon } from "../../../../../../res/img/feather-customised/check.svg";
import { formatRelativeTime } from "../../../../../DateUtils";
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
import { _t } from "../../../../../languageHandler";
import Modal, { IHandle } from "../../../../../Modal";
import MemberAvatar from "../../../avatars/MemberAvatar";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import AccessibleButton from "../../../elements/AccessibleButton";
import SettingsFieldset from "../../SettingsFieldset";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsTab from "../SettingsTab";
const Timestamp: VFC<{ roomMember: RoomMember }> = ({ roomMember }) => {
const timestamp = roomMember.events.member?.event.origin_server_ts;
if (!timestamp) return null;
return <time className="mx_PeopleRoomSettingsTab_timestamp">{formatRelativeTime(new Date(timestamp))}</time>;
};
const SeeMoreOrLess: VFC<{ roomMember: RoomMember }> = ({ roomMember }) => {
const [seeMore, setSeeMore] = useState(false);
const reason = roomMember.events.member?.getContent().reason;
if (!reason) return null;
const truncateAt = 120;
const shouldTruncate = reason.length > truncateAt;
return (
<>
<p className="mx_PeopleRoomSettingsTab_seeMoreOrLess">
{seeMore || !shouldTruncate ? reason : `${reason.substring(0, truncateAt)}`}
</p>
{shouldTruncate && (
<AccessibleButton kind="link" onClick={() => setSeeMore(!seeMore)}>
{seeMore ? _t("room_settings|people|see_less") : _t("room_settings|people|see_more")}
</AccessibleButton>
)}
</>
);
};
const Knock: VFC<{
canInvite: boolean;
canKick: boolean;
onApprove: (userId: string) => Promise<void>;
onDeny: (userId: string) => Promise<void>;
roomMember: RoomMember;
}> = ({ canKick, canInvite, onApprove, onDeny, roomMember }) => {
const [disabled, setDisabled] = useState(false);
const handleApprove = (userId: string): void => {
setDisabled(true);
onApprove(userId).catch(onError);
};
const handleDeny = (userId: string): void => {
setDisabled(true);
onDeny(userId).catch(onError);
};
const onError = (): void => setDisabled(false);
return (
<div className="mx_PeopleRoomSettingsTab_knock">
<MemberAvatar className="mx_PeopleRoomSettingsTab_avatar" member={roomMember} size="42px" />
<div className="mx_PeopleRoomSettingsTab_content">
<span className="mx_PeopleRoomSettingsTab_name">{roomMember.name}</span>
<Timestamp roomMember={roomMember} />
<span className="mx_PeopleRoomSettingsTab_userId">{roomMember.userId}</span>
<SeeMoreOrLess roomMember={roomMember} />
</div>
<AccessibleButton
className="mx_PeopleRoomSettingsTab_action"
disabled={!canKick || disabled}
kind="icon_primary_outline"
onClick={() => handleDeny(roomMember.userId)}
title={_t("action|deny")}
>
<CloseIcon width={18} height={18} />
</AccessibleButton>
<AccessibleButton
className="mx_PeopleRoomSettingsTab_action"
disabled={!canInvite || disabled}
kind="icon_primary"
onClick={() => handleApprove(roomMember.userId)}
title={_t("action|approve")}
>
<CheckIcon width={18} height={18} />
</AccessibleButton>
</div>
);
};
export const PeopleRoomSettingsTab: VFC<{ room: Room }> = ({ room }) => {
const client = room.client;
const userId = client.getUserId() || "";
const canInvite = room.canInvite(userId);
const member = room.getMember(userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canKick = member && state ? state.hasSufficientPowerLevelFor("kick", member.powerLevel) : false;
const roomId = room.roomId;
const handleApprove = (userId: string): Promise<void> =>
new Promise((_, reject) =>
client.invite(roomId, userId).catch((error) => {
onError(error);
reject(error);
}),
);
const handleDeny = (userId: string): Promise<void> =>
new Promise((_, reject) =>
client.kick(roomId, userId).catch((error) => {
onError(error);
reject(error);
}),
);
const onError = (error: MatrixError): IHandle<typeof ErrorDialog> =>
Modal.createDialog(ErrorDialog, {
title: error.name,
description: error.message,
});
const knockMembers = useTypedEventEmitterState(
room,
RoomStateEvent.Update,
useCallback(() => room.getMembersWithMembership(KnownMembership.Knock), [room]),
);
return (
<SettingsTab>
<SettingsSection heading={_t("common|people")}>
<SettingsFieldset legend={_t("room_settings|people|knock_section")}>
{knockMembers.length ? (
knockMembers.map((knockMember) => (
<Knock
canInvite={canInvite}
canKick={canKick}
key={knockMember.userId}
onApprove={handleApprove}
onDeny={handleDeny}
roomMember={knockMember}
/>
))
) : (
<p className="mx_PeopleRoomSettingsTab_paragraph">{_t("room_settings|people|knock_empty")}</p>
)}
</SettingsFieldset>
</SettingsSection>
</SettingsTab>
);
};

View file

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { PollHistory } from "../../../polls/pollHistory/PollHistory";
import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
interface IProps {
room: Room;
onFinished: () => void;
}
export const PollHistoryTab: React.FC<IProps> = ({ room, onFinished }) => {
const matrixClient = useContext(MatrixClientContext);
const permalinkCreator = new RoomPermalinkCreator(room, room.roomId);
return (
<div className="mx_SettingsTab">
<PollHistory
room={room}
permalinkCreator={permalinkCreator}
matrixClient={matrixClient}
onFinished={onFinished}
/>
</div>
);
};

View file

@ -0,0 +1,476 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
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 { EventType, RoomMember, RoomState, RoomStateEvent, Room, IContent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { throttle, get } from "lodash";
import { KnownMembership, RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types";
import { _t, _td, TranslationKey } from "../../../../../languageHandler";
import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import PowerSelector from "../../../elements/PowerSelector";
import SettingsFieldset from "../../SettingsFieldset";
import SettingsStore from "../../../../../settings/SettingsStore";
import { VoiceBroadcastInfoEventType } from "../../../../../voice-broadcast";
import { ElementCall } from "../../../../../models/Call";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
import { AddPrivilegedUsers } from "../../AddPrivilegedUsers";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { PowerLevelSelector } from "../../PowerLevelSelector";
interface IEventShowOpts {
isState?: boolean;
hideForSpace?: boolean;
hideForRoom?: boolean;
}
interface IPowerLevelDescriptor {
desc: string;
defaultValue: number;
hideForSpace?: boolean;
}
const plEventsToShow: Record<string, IEventShowOpts> = {
// If an event is listed here, it will be shown in the PL settings. Defaults will be calculated.
[EventType.RoomAvatar]: { isState: true },
[EventType.RoomName]: { isState: true },
[EventType.RoomCanonicalAlias]: { isState: true },
[EventType.SpaceChild]: { isState: true, hideForRoom: true },
[EventType.RoomHistoryVisibility]: { isState: true, hideForSpace: true },
[EventType.RoomPowerLevels]: { isState: true },
[EventType.RoomTopic]: { isState: true },
[EventType.RoomTombstone]: { isState: true, hideForSpace: true },
[EventType.RoomEncryption]: { isState: true, hideForSpace: true },
[EventType.RoomServerAcl]: { isState: true, hideForSpace: true },
[EventType.RoomPinnedEvents]: { isState: true, hideForSpace: true },
[EventType.Reaction]: { isState: false, hideForSpace: true },
[EventType.RoomRedaction]: { isState: false, hideForSpace: true },
// MSC3401: Native Group VoIP signaling
[ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
[ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": { isState: true, hideForSpace: true },
[VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true },
};
// parse a string as an integer; if the input is undefined, or cannot be parsed
// as an integer, return a default.
function parseIntWithDefault(val: string, def: number): number {
const res = parseInt(val);
return isNaN(res) ? def : res;
}
interface IBannedUserProps {
canUnban?: boolean;
member: RoomMember;
by: string;
reason?: string;
}
export class BannedUser extends React.Component<IBannedUserProps> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
private onUnbanClick = (): void => {
this.context.unban(this.props.member.roomId, this.props.member.userId).catch((err) => {
logger.error("Failed to unban: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("room_settings|permissions|error_unbanning"),
});
});
};
public render(): React.ReactNode {
let unbanButton;
if (this.props.canUnban) {
unbanButton = (
<AccessibleButton
className="mx_RolesRoomSettingsTab_unbanBtn"
kind="danger_sm"
onClick={this.onUnbanClick}
>
{_t("action|unban")}
</AccessibleButton>
);
}
const userId = this.props.member.name === this.props.member.userId ? null : this.props.member.userId;
return (
<li>
{unbanButton}
<span title={_t("room_settings|permissions|banned_by", { displayName: this.props.by })}>
<strong>{this.props.member.name}</strong> {userId}
{this.props.reason
? " " + _t("room_settings|permissions|ban_reason") + ": " + this.props.reason
: ""}
</span>
</li>
);
}
}
interface IProps {
room: Room;
}
export default class RolesRoomSettingsTab extends React.Component<IProps> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public componentDidMount(): void {
this.context.on(RoomStateEvent.Update, this.onRoomStateUpdate);
}
public componentWillUnmount(): void {
const client = this.context;
if (client) {
client.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
}
}
private onRoomStateUpdate = (state: RoomState): void => {
if (state.roomId !== this.props.room.roomId) return;
this.onThisRoomMembership();
};
private onThisRoomMembership = throttle(
() => {
this.forceUpdate();
},
200,
{ leading: true, trailing: true },
);
private populateDefaultPlEvents(
eventsSection: Record<string, number>,
stateLevel: number,
eventsLevel: number,
): void {
for (const desiredEvent of Object.keys(plEventsToShow)) {
if (!(desiredEvent in eventsSection)) {
eventsSection[desiredEvent] = plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel;
}
}
}
private onPowerLevelsChanged = async (value: number, powerLevelKey: string): Promise<void> => {
const client = this.context;
const room = this.props.room;
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
let plContent = plEvent?.getContent<RoomPowerLevelsEventContent>() ?? {};
// Clone the power levels just in case
plContent = Object.assign({}, plContent);
const eventsLevelPrefix = "event_levels_";
if (powerLevelKey.startsWith(eventsLevelPrefix)) {
// deep copy "events" object, Object.assign itself won't deep copy
plContent["events"] = Object.assign({}, plContent["events"] || {});
plContent["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value;
} else {
const keyPath = powerLevelKey.split(".");
let parentObj: IContent = {};
let currentObj: IContent = plContent;
for (const key of keyPath) {
if (!currentObj[key]) {
currentObj[key] = {};
}
parentObj = currentObj;
currentObj = currentObj[key];
}
parentObj[keyPath[keyPath.length - 1]] = value;
}
try {
await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent);
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("room_settings|permissions|error_changing_pl_reqs_title"),
description: _t("room_settings|permissions|error_changing_pl_reqs_description"),
});
// Rethrow so that the PowerSelector can roll back
throw e;
}
};
private onUserPowerLevelChanged = async (value: number, powerLevelKey: string): Promise<void> => {
const client = this.context;
const room = this.props.room;
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
let plContent = plEvent?.getContent<RoomPowerLevelsEventContent>() ?? {};
// Clone the power levels just in case
plContent = Object.assign({}, plContent);
// powerLevelKey should be a user ID
if (!plContent["users"]) plContent["users"] = {};
plContent["users"][powerLevelKey] = value;
try {
await client.sendStateEvent(this.props.room.roomId, EventType.RoomPowerLevels, plContent);
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("room_settings|permissions|error_changing_pl_title"),
description: _t("room_settings|permissions|error_changing_pl_description"),
});
}
};
public render(): React.ReactNode {
const client = this.context;
const room = this.props.room;
const isSpaceRoom = room.isSpaceRoom();
const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
const plContent = plEvent ? plEvent.getContent() || {} : {};
const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client);
const plEventsToLabels: Record<EventType | string, TranslationKey | null> = {
// These will be translated for us later.
[EventType.RoomAvatar]: isSpaceRoom
? _td("room_settings|permissions|m.room.avatar_space")
: _td("room_settings|permissions|m.room.avatar"),
[EventType.RoomName]: isSpaceRoom
? _td("room_settings|permissions|m.room.name_space")
: _td("room_settings|permissions|m.room.name"),
[EventType.RoomCanonicalAlias]: isSpaceRoom
? _td("room_settings|permissions|m.room.canonical_alias_space")
: _td("room_settings|permissions|m.room.canonical_alias"),
[EventType.SpaceChild]: _td("room_settings|permissions|m.space.child"),
[EventType.RoomHistoryVisibility]: _td("room_settings|permissions|m.room.history_visibility"),
[EventType.RoomPowerLevels]: _td("room_settings|permissions|m.room.power_levels"),
[EventType.RoomTopic]: isSpaceRoom
? _td("room_settings|permissions|m.room.topic_space")
: _td("room_settings|permissions|m.room.topic"),
[EventType.RoomTombstone]: _td("room_settings|permissions|m.room.tombstone"),
[EventType.RoomEncryption]: _td("room_settings|permissions|m.room.encryption"),
[EventType.RoomServerAcl]: _td("room_settings|permissions|m.room.server_acl"),
[EventType.Reaction]: _td("room_settings|permissions|m.reaction"),
[EventType.RoomRedaction]: _td("room_settings|permissions|m.room.redaction"),
[EventType.RoomPinnedEvents]: _td("room_settings|permissions|m.room.pinned_events"),
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": isSpaceRoom ? null : _td("room_settings|permissions|m.widget"),
[VoiceBroadcastInfoEventType]: _td("room_settings|permissions|io.element.voice_broadcast_info"),
};
// MSC3401: Native Group VoIP signaling
if (SettingsStore.getValue("feature_group_calls")) {
plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("room_settings|permissions|m.call");
plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("room_settings|permissions|m.call.member");
}
const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
"users_default": {
desc: _t("room_settings|permissions|users_default"),
defaultValue: 0,
},
"events_default": {
desc: _t("room_settings|permissions|events_default"),
defaultValue: 0,
hideForSpace: true,
},
"invite": {
desc: _t("room_settings|permissions|invite"),
defaultValue: 0,
},
"state_default": {
desc: _t("room_settings|permissions|state_default"),
defaultValue: 50,
},
"kick": {
desc: _t("room_settings|permissions|kick"),
defaultValue: 50,
},
"ban": {
desc: _t("room_settings|permissions|ban"),
defaultValue: 50,
},
"redact": {
desc: _t("room_settings|permissions|redact"),
defaultValue: 50,
hideForSpace: true,
},
"notifications.room": {
desc: _t("room_settings|permissions|notifications.room"),
defaultValue: 50,
hideForSpace: true,
},
};
const eventsLevels = plContent.events || {};
const userLevels = plContent.users || {};
const banLevel = parseIntWithDefault(plContent.ban, powerLevelDescriptors.ban.defaultValue);
const defaultUserLevel = parseIntWithDefault(
plContent.users_default,
powerLevelDescriptors.users_default.defaultValue,
);
let currentUserLevel = userLevels[client.getUserId()!];
if (currentUserLevel === undefined) {
currentUserLevel = defaultUserLevel;
}
this.populateDefaultPlEvents(
eventsLevels,
parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue),
parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue),
);
let privilegedUsersSection = <div>{_t("room_settings|permissions|no_privileged_users")}</div>;
let mutedUsersSection;
if (Object.keys(userLevels).length) {
privilegedUsersSection = (
<PowerLevelSelector
title={_t("room_settings|permissions|privileged_users_section")}
userLevels={userLevels}
canChangeLevels={canChangeLevels}
currentUserLevel={currentUserLevel}
onClick={this.onUserPowerLevelChanged}
filter={(user) => userLevels[user] > defaultUserLevel}
>
<div>{_t("room_settings|permissions|no_privileged_users")}</div>
</PowerLevelSelector>
);
mutedUsersSection = (
<PowerLevelSelector
title={_t("room_settings|permissions|muted_users_section")}
userLevels={userLevels}
canChangeLevels={canChangeLevels}
currentUserLevel={currentUserLevel}
onClick={this.onUserPowerLevelChanged}
filter={(user) => userLevels[user] < defaultUserLevel}
/>
);
}
const banned = room.getMembersWithMembership(KnownMembership.Ban);
let bannedUsersSection: JSX.Element | undefined;
if (banned?.length) {
const canBanUsers = currentUserLevel >= banLevel;
bannedUsersSection = (
<SettingsFieldset legend={_t("room_settings|permissions|banned_users_section")}>
<ul className="mx_RolesRoomSettingsTab_bannedList">
{banned.map((member) => {
const banEvent = member.events.member?.getContent();
const bannedById = member.events.member?.getSender();
const sender = bannedById ? room.getMember(bannedById) : undefined;
const bannedBy = sender?.name || bannedById; // fallback to mxid
return (
<BannedUser
key={member.userId}
canUnban={canBanUsers}
member={member}
reason={banEvent?.reason}
by={bannedBy!}
/>
);
})}
</ul>
</SettingsFieldset>
);
}
const powerSelectors = Object.keys(powerLevelDescriptors)
.map((key, index) => {
const descriptor = powerLevelDescriptors[key];
if (isSpaceRoom && descriptor.hideForSpace) {
return null;
}
const value = parseIntWithDefault(get(plContent, key), descriptor.defaultValue);
return (
<div key={index} className="">
<PowerSelector
label={descriptor.desc}
value={value}
usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < value}
powerLevelKey={key} // Will be sent as the second parameter to `onChange`
onChange={this.onPowerLevelsChanged}
/>
</div>
);
})
.filter(Boolean);
// hide the power level selector for enabling E2EE if it the room is already encrypted
if (client.isRoomEncrypted(this.props.room.roomId)) {
delete eventsLevels[EventType.RoomEncryption];
}
const eventPowerSelectors = Object.keys(eventsLevels)
.map((eventType, i) => {
if (isSpaceRoom && plEventsToShow[eventType]?.hideForSpace) {
return null;
} else if (!isSpaceRoom && plEventsToShow[eventType]?.hideForRoom) {
return null;
}
const translationKeyForEvent = plEventsToLabels[eventType];
let label: string;
if (translationKeyForEvent) {
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
label = _t(translationKeyForEvent, { brand });
} else {
label = _t("room_settings|permissions|send_event_type", { eventType });
}
return (
<div key={eventType}>
<PowerSelector
label={label}
value={eventsLevels[eventType]}
usersDefault={defaultUserLevel}
disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]}
powerLevelKey={"event_levels_" + eventType}
onChange={this.onPowerLevelsChanged}
/>
</div>
);
})
.filter(Boolean);
return (
<SettingsTab>
<SettingsSection heading={_t("room_settings|permissions|title")}>
{privilegedUsersSection}
{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>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,468 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
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 {
GuestAccess,
HistoryVisibility,
JoinRule,
MatrixEvent,
RoomStateEvent,
Room,
EventType,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Icon as WarningIcon } from "../../../../../../res/img/warning.svg";
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup from "../../../elements/StyledRadioGroup";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore";
import { UIFeature } from "../../../../../settings/UIFeature";
import AccessibleButton from "../../../elements/AccessibleButton";
import SettingsFlag from "../../../elements/SettingsFlag";
import createRoom from "../../../../../createRoom";
import CreateRoomDialog from "../../../dialogs/CreateRoomDialog";
import JoinRuleSettings from "../../JoinRuleSettings";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import SettingsFieldset from "../../SettingsFieldset";
import ExternalLink from "../../../elements/ExternalLink";
import PosthogTrackers from "../../../../../PosthogTrackers";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsTab from "../SettingsTab";
import SdkConfig from "../../../../../SdkConfig";
import { shouldForceDisableEncryption } from "../../../../../utils/crypto/shouldForceDisableEncryption";
import { Caption } from "../../../typography/Caption";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../../utils/crypto";
interface IProps {
room: Room;
closeSettingsFn: () => void;
}
interface IState {
guestAccess: GuestAccess;
history: HistoryVisibility;
hasAliases: boolean;
encrypted: boolean;
showAdvancedSection: boolean;
}
export default class SecurityRoomSettingsTab extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const state = this.props.room.currentState;
this.state = {
guestAccess: this.pullContentPropertyFromEvent<GuestAccess>(
state?.getStateEvents(EventType.RoomGuestAccess, ""),
"guest_access",
GuestAccess.Forbidden,
),
history: this.pullContentPropertyFromEvent<HistoryVisibility>(
state?.getStateEvents(EventType.RoomHistoryVisibility, ""),
"history_visibility",
HistoryVisibility.Shared,
),
hasAliases: false, // async loaded in componentDidMount
encrypted: context.isRoomEncrypted(this.props.room.roomId),
showAdvancedSection: false,
};
}
public componentDidMount(): void {
this.context.on(RoomStateEvent.Events, this.onStateEvent);
this.hasAliases().then((hasAliases) => this.setState({ hasAliases }));
}
private pullContentPropertyFromEvent<T>(event: MatrixEvent | null | undefined, key: string, defaultValue: T): T {
return event?.getContent()[key] || defaultValue;
}
public componentWillUnmount(): void {
this.context.removeListener(RoomStateEvent.Events, this.onStateEvent);
}
private onStateEvent = (e: MatrixEvent): void => {
const refreshWhenTypes: EventType[] = [
EventType.RoomJoinRules,
EventType.RoomGuestAccess,
EventType.RoomHistoryVisibility,
EventType.RoomEncryption,
];
if (refreshWhenTypes.includes(e.getType() as EventType)) this.forceUpdate();
};
private onEncryptionChange = async (): Promise<void> => {
if (this.props.room.getJoinRule() === JoinRule.Public) {
const dialog = Modal.createDialog(QuestionDialog, {
title: _t("room_settings|security|enable_encryption_public_room_confirm_title"),
description: (
<div>
<p>
{" "}
{_t(
"room_settings|security|enable_encryption_public_room_confirm_description_1",
undefined,
{ b: (sub) => <strong>{sub}</strong> },
)}{" "}
</p>
<p>
{" "}
{_t(
"room_settings|security|enable_encryption_public_room_confirm_description_2",
undefined,
{
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={() => {
dialog.close();
this.createNewRoom(false, true);
}}
>
{" "}
{sub}{" "}
</AccessibleButton>
),
},
)}{" "}
</p>
</div>
),
});
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return;
}
Modal.createDialog(QuestionDialog, {
title: _t("room_settings|security|enable_encryption_confirm_title"),
description: _t(
"room_settings|security|enable_encryption_confirm_description",
{},
{
a: (sub) => <ExternalLink href={SdkConfig.get("help_encryption_url")}>{sub}</ExternalLink>,
},
),
onFinished: (confirm) => {
if (!confirm) {
this.setState({ encrypted: false });
return;
}
const beforeEncrypted = this.state.encrypted;
this.setState({ encrypted: true });
this.context
.sendStateEvent(this.props.room.roomId, EventType.RoomEncryption, {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
})
.catch((e) => {
logger.error(e);
this.setState({ encrypted: beforeEncrypted });
});
},
});
};
private onGuestAccessChange = (allowed: boolean): void => {
const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
const beforeGuestAccess = this.state.guestAccess;
if (beforeGuestAccess === guestAccess) return;
this.setState({ guestAccess });
this.context
.sendStateEvent(
this.props.room.roomId,
EventType.RoomGuestAccess,
{
guest_access: guestAccess,
},
"",
)
.catch((e) => {
logger.error(e);
this.setState({ guestAccess: beforeGuestAccess });
});
};
private createNewRoom = async (defaultPublic: boolean, defaultEncrypted: boolean): Promise<boolean> => {
const modal = Modal.createDialog(CreateRoomDialog, { defaultPublic, defaultEncrypted });
PosthogTrackers.trackInteraction("WebRoomSettingsSecurityTabCreateNewRoomButton");
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
await createRoom(this.context, opts);
}
return shouldCreate;
};
private onHistoryRadioToggle = (history: HistoryVisibility): void => {
const beforeHistory = this.state.history;
if (beforeHistory === history) return;
this.setState({ history: history });
this.context
.sendStateEvent(
this.props.room.roomId,
EventType.RoomHistoryVisibility,
{
history_visibility: history,
},
"",
)
.catch((e) => {
logger.error(e);
this.setState({ history: beforeHistory });
});
};
private updateBlacklistDevicesFlag = (checked: boolean): void => {
this.props.room.setBlacklistUnverifiedDevices(checked);
};
private async hasAliases(): Promise<boolean> {
const cli = this.context;
const response = await cli.getLocalAliases(this.props.room.roomId);
const localAliases = response.aliases;
return Array.isArray(localAliases) && localAliases.length !== 0;
}
private renderJoinRule(): JSX.Element {
const room = this.props.room;
let aliasWarning: JSX.Element | undefined;
if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) {
aliasWarning = (
<div className="mx_SecurityRoomSettingsTab_warning">
<WarningIcon width={15} height={15} />
<span>{_t("room_settings|security|public_without_alias_warning")}</span>
</div>
);
}
const description = _t("room_settings|security|join_rule_description", {
roomName: room.name,
});
let advanced: JSX.Element | undefined;
if (room.getJoinRule() === JoinRule.Public) {
advanced = (
<div>
<AccessibleButton
onClick={this.toggleAdvancedSection}
kind="link"
className="mx_SettingsTab_showAdvanced"
aria-expanded={this.state.showAdvancedSection}
>
{this.state.showAdvancedSection ? _t("action|hide_advanced") : _t("action|show_advanced")}
</AccessibleButton>
{this.state.showAdvancedSection && this.renderAdvanced()}
</div>
);
}
return (
<SettingsFieldset legend={_t("room_settings|access|title")} description={description}>
<JoinRuleSettings
room={room}
beforeChange={this.onBeforeJoinRuleChange}
onError={this.onJoinRuleChangeError}
closeSettingsFn={this.props.closeSettingsFn}
promptUpgrade={true}
aliasWarning={aliasWarning}
/>
{advanced}
</SettingsFieldset>
);
}
private onJoinRuleChangeError = (error: Error): void => {
Modal.createDialog(ErrorDialog, {
title: _t("room_settings|security|error_join_rule_change_title"),
description: error.message ?? _t("room_settings|security|error_join_rule_change_unknown"),
});
};
private onBeforeJoinRuleChange = async (joinRule: JoinRule): Promise<boolean> => {
if (this.state.encrypted && joinRule === JoinRule.Public) {
const dialog = Modal.createDialog(QuestionDialog, {
title: _t("room_settings|security|encrypted_room_public_confirm_title"),
description: (
<div>
<p>
{" "}
{_t("room_settings|security|encrypted_room_public_confirm_description_1", undefined, {
b: (sub) => <strong>{sub}</strong>,
})}{" "}
</p>
<p>
{" "}
{_t("room_settings|security|encrypted_room_public_confirm_description_2", undefined, {
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={(): void => {
dialog.close();
this.createNewRoom(true, false);
}}
>
{" "}
{sub}{" "}
</AccessibleButton>
),
})}{" "}
</p>
</div>
),
});
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return false;
}
return true;
};
private renderHistory(): ReactNode {
if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) {
return null;
}
const client = this.context;
const history = this.state.history;
const state = this.props.room.currentState;
const canChangeHistory = state?.mayClientSendStateEvent(EventType.RoomHistoryVisibility, client);
const options = [
{
value: HistoryVisibility.Shared,
label: _t("room_settings|security|history_visibility_shared"),
},
{
value: HistoryVisibility.Invited,
label: _t("room_settings|security|history_visibility_invited"),
},
{
value: HistoryVisibility.Joined,
label: _t("room_settings|security|history_visibility_joined"),
},
];
// World readable doesn't make sense for encrypted rooms
if (!this.state.encrypted || history === HistoryVisibility.WorldReadable) {
options.unshift({
value: HistoryVisibility.WorldReadable,
label: _t("room_settings|security|history_visibility_world_readable"),
});
}
const description = _t("room_settings|security|history_visibility_warning");
return (
<SettingsFieldset legend={_t("room_settings|security|history_visibility_legend")} description={description}>
<StyledRadioGroup
name="historyVis"
value={history}
onChange={this.onHistoryRadioToggle}
disabled={!canChangeHistory}
definitions={options}
/>
</SettingsFieldset>
);
}
private toggleAdvancedSection = (): void => {
this.setState({ showAdvancedSection: !this.state.showAdvancedSection });
};
private renderAdvanced(): JSX.Element {
const client = this.context;
const guestAccess = this.state.guestAccess;
const state = this.props.room.currentState;
const canSetGuestAccess = state?.mayClientSendStateEvent(EventType.RoomGuestAccess, client);
return (
<div className="mx_SecurityRoomSettingsTab_advancedSection">
<LabelledToggleSwitch
value={guestAccess === GuestAccess.CanJoin}
onChange={this.onGuestAccessChange}
disabled={!canSetGuestAccess}
label={_t("room_settings|visibility|guest_access_label")}
/>
<p>{_t("room_settings|security|guest_access_warning")}</p>
</div>
);
}
public render(): React.ReactNode {
const client = this.context;
const room = this.props.room;
const isEncrypted = this.state.encrypted;
const hasEncryptionPermission = room.currentState.mayClientSendStateEvent(EventType.RoomEncryption, client);
const isEncryptionForceDisabled = shouldForceDisableEncryption(client);
const canEnableEncryption = !isEncrypted && !isEncryptionForceDisabled && hasEncryptionPermission;
let encryptionSettings: JSX.Element | undefined;
if (
isEncrypted &&
SettingsStore.canSetValue("blacklistUnverifiedDevices", this.props.room.roomId, SettingLevel.ROOM_DEVICE)
) {
encryptionSettings = (
<SettingsFlag
name="blacklistUnverifiedDevices"
level={SettingLevel.ROOM_DEVICE}
onChange={this.updateBlacklistDevicesFlag}
roomId={this.props.room.roomId}
/>
);
}
const historySection = this.renderHistory();
return (
<SettingsTab>
<SettingsSection heading={_t("room_settings|security|title")}>
<SettingsFieldset
legend={_t("settings|security|encryption_section")}
description={
isEncryptionForceDisabled && !isEncrypted
? undefined
: _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>
)}
{encryptionSettings}
</SettingsFieldset>
{this.renderJoinRule()}
{historySection}
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,102 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, useMemo, useState } from "react";
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 SettingsTab from "../SettingsTab";
import { ElementCall } from "../../../../../models/Call";
import { useRoomState } from "../../../../../hooks/useRoomState";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
import { SettingsSection } from "../../shared/SettingsSection";
interface ElementCallSwitchProps {
room: Room;
}
const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]);
const [content, events, maySend] = useRoomState(
room,
useCallback(
(state: RoomState) => {
const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
return [
content ?? {},
content?.["events"] ?? {},
state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()),
];
},
[room.client],
),
);
const [elementCallEnabled, setElementCallEnabled] = useState<boolean>(() => {
return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
});
const onChange = useCallback(
(enabled: boolean): void => {
setElementCallEnabled(enabled);
if (enabled) {
const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0;
const moderatorLevel = content.kick ?? 50;
events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel;
} else {
const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100;
events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel;
events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel;
}
room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, {
events: events,
...content,
});
},
[room.client, room.roomId, content, events, isPublic],
);
const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand;
return (
<LabelledToggleSwitch
data-testid="element-call-switch"
label={_t("room_settings|voip|enable_element_call_label", { brand })}
caption={_t("room_settings|voip|enable_element_call_caption", {
brand,
})}
value={elementCallEnabled}
onChange={onChange}
disabled={!maySend}
tooltip={_t("room_settings|voip|enable_element_call_no_permissions_tooltip")}
/>
);
};
interface Props {
room: Room;
}
export const VoipRoomSettingsTab: React.FC<Props> = ({ room }) => {
return (
<SettingsTab>
<SettingsSection heading={_t("settings|voip|title")}>
<SettingsSubsection heading={_t("room_settings|voip|call_type_section")}>
<ElementCallSwitch room={room} />
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
};

View file

@ -0,0 +1,201 @@
/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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, useContext, useEffect } from "react";
import { HTTPError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { UserFriendlyError, _t } from "../../../../../languageHandler";
import UserProfileSettings from "../../UserProfileSettings";
import SettingsStore from "../../../../../settings/SettingsStore";
import AccessibleButton from "../../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
import Modal from "../../../../../Modal";
import { UIFeature } from "../../../../../settings/UIFeature";
import ErrorDialog, { extractErrorMessageFromError } from "../../../dialogs/ErrorDialog";
import ChangePassword from "../../ChangePassword";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
import { SDKContext } from "../../../../../contexts/SDKContext";
import UserPersonalInfoSettings from "../../UserPersonalInfoSettings";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
interface IProps {
closeSettingsFn: () => void;
}
interface AccountSectionProps {
canChangePassword: boolean;
onPasswordChangeError: (e: Error) => void;
onPasswordChanged: () => void;
}
const AccountSection: React.FC<AccountSectionProps> = ({
canChangePassword,
onPasswordChangeError,
onPasswordChanged,
}) => {
if (!canChangePassword) return <></>;
return (
<>
<SettingsSubsection
heading={_t("settings|general|account_section")}
stretchContent
data-testid="accountSection"
>
<SettingsSubsectionText>{_t("settings|general|password_change_section")}</SettingsSubsectionText>
<ChangePassword
rowClassName=""
buttonKind="primary"
onError={onPasswordChangeError}
onFinished={onPasswordChanged}
/>
</SettingsSubsection>
</>
);
};
interface ManagementSectionProps {
onDeactivateClicked: () => void;
}
const ManagementSection: React.FC<ManagementSectionProps> = ({ onDeactivateClicked }) => {
return (
<SettingsSection heading={_t("settings|general|deactivate_section")}>
<SettingsSubsection
heading={_t("settings|general|account_management_section")}
data-testid="account-management-section"
description={_t("settings|general|deactivate_warning")}
>
<AccessibleButton onClick={onDeactivateClicked} kind="danger">
{_t("settings|general|deactivate_section")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection>
);
};
const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
const [externalAccountManagementUrl, setExternalAccountManagementUrl] = React.useState<string | undefined>();
const [canMake3pidChanges, setCanMake3pidChanges] = React.useState<boolean>(false);
const [canSetDisplayName, setCanSetDisplayName] = React.useState<boolean>(false);
const [canSetAvatar, setCanSetAvatar] = React.useState<boolean>(false);
const [canChangePassword, setCanChangePassword] = React.useState<boolean>(false);
const cli = useMatrixClientContext();
const sdkContext = useContext(SDKContext);
useEffect(() => {
(async () => {
const capabilities = (await cli.getCapabilities()) ?? {};
const changePasswordCap = capabilities["m.change_password"];
// You can change your password so long as the capability isn't explicitly disabled. The implicit
// behaviour is you can change your password when the capability is missing or has not-false as
// the enabled flag value.
const canChangePassword = !changePasswordCap || changePasswordCap["enabled"] !== false;
await sdkContext.oidcClientStore.readyPromise; // wait for the store to be ready
const externalAccountManagementUrl = sdkContext.oidcClientStore.accountManagementEndpoint;
// https://spec.matrix.org/v1.7/client-server-api/#m3pid_changes-capability
// We support as far back as v1.1 which doesn't have m.3pid_changes
// so the behaviour for when it is missing has to be assume true
const canMake3pidChanges =
!capabilities["m.3pid_changes"] || capabilities["m.3pid_changes"].enabled === true;
const canSetDisplayName =
!capabilities["m.set_displayname"] || capabilities["m.set_displayname"].enabled === true;
const canSetAvatar = !capabilities["m.set_avatar_url"] || capabilities["m.set_avatar_url"].enabled === true;
setCanMake3pidChanges(canMake3pidChanges);
setCanSetDisplayName(canSetDisplayName);
setCanSetAvatar(canSetAvatar);
setExternalAccountManagementUrl(externalAccountManagementUrl);
setCanChangePassword(canChangePassword);
})();
}, [cli, sdkContext.oidcClientStore]);
const onPasswordChangeError = useCallback((err: Error): void => {
logger.error("Failed to change password: " + err);
let underlyingError = err;
if (err instanceof UserFriendlyError && err.cause instanceof Error) {
underlyingError = err.cause;
}
const errorMessage = extractErrorMessageFromError(
err,
_t("settings|general|error_password_change_unknown", {
stringifiedError: String(err),
}),
);
let errorMessageToDisplay = errorMessage;
if (underlyingError instanceof HTTPError && underlyingError.httpStatus === 403) {
errorMessageToDisplay = _t("settings|general|error_password_change_403");
} else if (underlyingError instanceof HTTPError) {
errorMessageToDisplay = _t("settings|general|error_password_change_http", {
errorMessage,
httpStatus: underlyingError.httpStatus,
});
}
// TODO: Figure out a design that doesn't involve replacing the current dialog
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_password_change_title"),
description: errorMessageToDisplay,
});
}, []);
const onPasswordChanged = useCallback((): void => {
const description = _t("settings|general|password_change_success");
// TODO: Figure out a design that doesn't involve replacing the current dialog
Modal.createDialog(ErrorDialog, {
title: _t("common|success"),
description,
});
}, []);
const onDeactivateClicked = useCallback((): void => {
Modal.createDialog(DeactivateAccountDialog, {
onFinished: (success) => {
if (success) closeSettingsFn();
},
});
}, [closeSettingsFn]);
let accountManagementSection: JSX.Element | undefined;
const isAccountManagedExternally = Boolean(externalAccountManagementUrl);
if (SettingsStore.getValue(UIFeature.Deactivate) && !isAccountManagedExternally) {
accountManagementSection = <ManagementSection onDeactivateClicked={onDeactivateClicked} />;
}
return (
<SettingsTab data-testid="mx_AccountUserSettingsTab">
<SettingsSection>
<UserProfileSettings
externalAccountManagementUrl={externalAccountManagementUrl}
canSetDisplayName={canSetDisplayName}
canSetAvatar={canSetAvatar}
/>
<UserPersonalInfoSettings canMake3pidChanges={canMake3pidChanges} />
<AccountSection
canChangePassword={canChangePassword}
onPasswordChanged={onPasswordChanged}
onPasswordChangeError={onPasswordChangeError}
/>
</SettingsSection>
{accountManagementSection}
</SettingsTab>
);
};
export default AccountUserSettingsTab;

View file

@ -0,0 +1,122 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 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, { ChangeEvent, ReactNode } from "react";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import SettingsStore from "../../../../../settings/SettingsStore";
import SettingsFlag from "../../../elements/SettingsFlag";
import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature";
import { LayoutSwitcher } from "../../LayoutSwitcher";
import FontScalingPanel from "../../FontScalingPanel";
import { ThemeChoicePanel } from "../../ThemeChoicePanel";
import ImageSizePanel from "../../ImageSizePanel";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
interface IProps {}
interface IState {
useBundledEmojiFont: boolean;
useSystemFont: boolean;
systemFont: string;
showAdvanced: boolean;
}
export default class AppearanceUserSettingsTab extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
useBundledEmojiFont: SettingsStore.getValue("useBundledEmojiFont"),
useSystemFont: SettingsStore.getValue("useSystemFont"),
systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false,
};
}
private renderAdvancedSection(): ReactNode {
if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
const brand = SdkConfig.get().brand;
const toggle = (
<AccessibleButton
kind="link"
onClick={() => this.setState({ showAdvanced: !this.state.showAdvanced })}
aria-expanded={this.state.showAdvanced}
>
{this.state.showAdvanced ? _t("action|hide_advanced") : _t("action|show_advanced")}
</AccessibleButton>
);
let advanced: React.ReactNode;
if (this.state.showAdvanced) {
const tooltipContent = _t("settings|appearance|custom_font_description", { brand });
advanced = (
<>
<SettingsFlag name="useCompactLayout" level={SettingLevel.DEVICE} useCheckbox={true} />
<SettingsFlag
name="useBundledEmojiFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useBundledEmojiFont: checked })}
/>
<SettingsFlag
name="useSystemFont"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({ useSystemFont: checked })}
/>
<Field
className="mx_AppearanceUserSettingsTab_checkboxControlledField"
label={SettingsStore.getDisplayName("systemFont")!}
onChange={(value: ChangeEvent<HTMLInputElement>) => {
this.setState({
systemFont: value.target.value,
});
SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value);
}}
tooltipContent={tooltipContent}
forceTooltipVisible={true}
disabled={!this.state.useSystemFont}
value={this.state.systemFont}
/>
</>
);
}
return (
<SettingsSubsection>
{toggle}
{advanced}
</SettingsSubsection>
);
}
public render(): React.ReactNode {
return (
<SettingsTab data-testid="mx_AppearanceUserSettingsTab">
<SettingsSection>
<ThemeChoicePanel />
<LayoutSwitcher />
<FontScalingPanel />
{this.renderAdvancedSection()}
<ImageSizePanel />
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,319 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
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 { logger } from "matrix-js-sdk/src/logger";
import AccessibleButton from "../../../elements/AccessibleButton";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import Modal from "../../../../../Modal";
import PlatformPeg from "../../../../../PlatformPeg";
import UpdateCheckButton from "../../UpdateCheckButton";
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 ExternalLink from "../../../elements/ExternalLink";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
interface IProps {}
interface IState {
appVersion: string | null;
canUpdate: boolean;
}
export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
appVersion: null,
canUpdate: false,
};
}
public componentDidMount(): void {
PlatformPeg.get()
?.getAppVersion()
.then((ver) => this.setState({ appVersion: ver }))
.catch((e) => {
logger.error("Error getting vector version: ", e);
});
PlatformPeg.get()
?.canSelfUpdate()
.then((v) => this.setState({ canUpdate: v }))
.catch((e) => {
logger.error("Error getting self updatability: ", e);
});
}
private getVersionInfo(): { appVersion: string; cryptoVersion: string } {
const brand = SdkConfig.get().brand;
const appVersion = this.state.appVersion || "unknown";
const cryptoVersion = this.context.getCrypto()?.getVersion() ?? "<not-enabled>";
return {
appVersion: `${_t("setting|help_about|brand_version", { brand })} ${appVersion}`,
cryptoVersion: `${_t("setting|help_about|crypto_version")} ${cryptoVersion}`,
};
}
private onClearCacheAndReload = (): void => {
if (!PlatformPeg.get()) return;
// Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly
// stopping in the middle of the logs.
logger.log("Clear cache & reload clicked");
this.context.stopClient();
this.context.store.deleteAllData().then(() => {
PlatformPeg.get()?.reload();
});
};
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {});
};
private renderLegal(): ReactNode {
const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null;
const legalLinks: JSX.Element[] = [];
for (const tocEntry of tocLinks) {
legalLinks.push(
<div key={tocEntry.url}>
<ExternalLink href={tocEntry.url}>{tocEntry.text}</ExternalLink>
</div>,
);
}
return (
<SettingsSubsection heading={_t("common|legal")}>
<SettingsSubsectionText>{legalLinks}</SettingsSubsectionText>
</SettingsSubsection>
);
}
private renderCredits(): JSX.Element {
// Note: This is not translated because it is legal text.
// Also, &nbsp; is ugly but necessary.
return (
<SettingsSubsection heading={_t("common|credits")}>
<SettingsSubsectionText>
<ul>
<li>
{_t(
"credits|default_cover_photo",
{},
{
photo: (sub) => (
<ExternalLink
href="themes/element/img/backgrounds/lake.jpg"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</ExternalLink>
),
author: (sub) => (
<ExternalLink href="https://www.flickr.com/golan">{sub}</ExternalLink>
),
terms: (sub) => (
<ExternalLink
href="https://creativecommons.org/licenses/by-sa/4.0/"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</ExternalLink>
),
},
)}
</li>
<li>
{_t(
"credits|twemoji_colr",
{},
{
colr: (sub) => (
<ExternalLink
href="https://github.com/matrix-org/twemoji-colr"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</ExternalLink>
),
author: (sub) => <ExternalLink href="https://mozilla.org">{sub}</ExternalLink>,
terms: (sub) => (
<ExternalLink
href="https://www.apache.org/licenses/LICENSE-2.0"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</ExternalLink>
),
},
)}
</li>
<li>
{_t(
"credits|twemoji",
{},
{
twemoji: (sub) => (
<ExternalLink href="https://twemoji.twitter.com/">{sub}</ExternalLink>
),
author: (sub) => (
<ExternalLink href="https://twemoji.twitter.com/">{sub}</ExternalLink>
),
terms: (sub) => (
<ExternalLink
href="https://creativecommons.org/licenses/by/4.0/"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</ExternalLink>
),
},
)}
</li>
</ul>
</SettingsSubsectionText>
</SettingsSubsection>
);
}
private getVersionTextToCopy = (): string => {
const { appVersion, cryptoVersion } = this.getVersionInfo();
return `${appVersion}\n${cryptoVersion}`;
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
const faqText = _t(
"setting|help_about|help_link",
{
brand,
},
{
a: (sub) => <ExternalLink href={SdkConfig.get("help_url")}>{sub}</ExternalLink>,
},
);
let updateButton: JSX.Element | undefined;
if (this.state.canUpdate) {
updateButton = <UpdateCheckButton />;
}
let bugReportingSection;
if (SdkConfig.get().bug_report_endpoint_url) {
bugReportingSection = (
<SettingsSubsection
heading={_t("bug_reporting|title")}
description={
<>
<SettingsSubsectionText>{_t("bug_reporting|introduction")}</SettingsSubsectionText>
{_t("bug_reporting|description")}
</>
}
>
<AccessibleButton onClick={this.onBugReport} kind="primary_outline">
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
<SettingsSubsectionText>
{_t(
"bug_reporting|matrix_security_issue",
{},
{
a: (sub) => (
<ExternalLink href="https://matrix.org/security-disclosure-policy/">
{sub}
</ExternalLink>
),
},
)}
</SettingsSubsectionText>
</SettingsSubsection>
);
}
const { appVersion, cryptoVersion } = this.getVersionInfo();
return (
<SettingsTab>
<SettingsSection>
{bugReportingSection}
<SettingsSubsection heading={_t("common|faq")} description={faqText} />
<SettingsSubsection heading={_t("setting|help_about|versions")}>
<SettingsSubsectionText>
<CopyableText getTextToCopy={this.getVersionTextToCopy}>
{appVersion}
<br />
{cryptoVersion}
<br />
</CopyableText>
{updateButton}
</SettingsSubsectionText>
</SettingsSubsection>
{this.renderLegal()}
{this.renderCredits()}
<SettingsSubsection heading={_t("common|advanced")}>
<SettingsSubsectionText>
{_t(
"setting|help_about|homeserver",
{
homeserverUrl: this.context.getHomeserverUrl(),
},
{
code: (sub) => <code>{sub}</code>,
},
)}
</SettingsSubsectionText>
{this.context.getIdentityServerUrl() && (
<SettingsSubsectionText>
{_t(
"setting|help_about|identity_server",
{
identityServerUrl: this.context.getIdentityServerUrl(),
},
{
code: (sub) => <code>{sub}</code>,
},
)}
</SettingsSubsectionText>
)}
<SettingsSubsectionText>
<details>
<summary className="mx_HelpUserSettingsTab_accessTokenDetails">
{_t("common|access_token")}
</summary>
<strong>{_t("setting|help_about|access_token_detail")}</strong>
<CopyableText getTextToCopy={() => this.context.getAccessToken()}>
{this.context.getAccessToken()}
</CopyableText>
</details>
</SettingsSubsectionText>
<AccessibleButton onClick={this.onClearCacheAndReload} kind="danger_outline">
{_t("setting|help_about|clear_cache_reload")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
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 { ICategory, CATEGORIES, CategoryName, KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { _t } from "../../../../../languageHandler";
import {
getKeyboardShortcutDisplayName,
getKeyboardShortcutValue,
} from "../../../../../accessibility/KeyboardShortcutUtils";
import { KeyboardShortcut } from "../../KeyboardShortcut";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
import { showLabsFlags } from "./LabsUserSettingsTab";
interface IKeyboardShortcutRowProps {
name: KeyBindingAction;
}
// Filter out the labs section if labs aren't enabled.
const visibleCategories = (Object.entries(CATEGORIES) as [CategoryName, ICategory][]).filter(
([categoryName]) => categoryName !== CategoryName.LABS || showLabsFlags(),
);
const KeyboardShortcutRow: React.FC<IKeyboardShortcutRowProps> = ({ name }) => {
const displayName = getKeyboardShortcutDisplayName(name);
const value = getKeyboardShortcutValue(name);
if (!displayName || !value) return null;
return (
<li className="mx_KeyboardShortcut_shortcutRow">
{displayName}
<KeyboardShortcut value={value} />
</li>
);
};
interface IKeyboardShortcutSectionProps {
categoryName: CategoryName;
category: ICategory;
}
const KeyboardShortcutSection: React.FC<IKeyboardShortcutSectionProps> = ({ categoryName, category }) => {
if (!category.categoryLabel) return null;
return (
<SettingsSubsection heading={_t(category.categoryLabel)} key={categoryName}>
<ul className="mx_KeyboardShortcut_shortcutList">
{category.settingNames.map((shortcutName) => {
return <KeyboardShortcutRow key={shortcutName} name={shortcutName} />;
})}
</ul>
</SettingsSubsection>
);
};
const KeyboardUserSettingsTab: React.FC = () => {
return (
<SettingsTab>
<SettingsSection>
{visibleCategories.map(([categoryName, category]) => {
return (
<KeyboardShortcutSection key={categoryName} categoryName={categoryName} category={category} />
);
})}
</SettingsSection>
</SettingsTab>
);
};
export default KeyboardUserSettingsTab;

View file

@ -0,0 +1,142 @@
/*
Copyright 2019-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 from "react";
import { sortBy } from "lodash";
import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import SdkConfig from "../../../../../SdkConfig";
import BetaCard from "../../../beta/BetaCard";
import SettingsFlag from "../../../elements/SettingsFlag";
import { LabGroup, labGroupNames } from "../../../../../settings/Settings";
import { EnhancedMap } from "../../../../../utils/maps";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
export const showLabsFlags = (): boolean => {
return SdkConfig.get("show_labs_settings") || SettingsStore.getValue("developerMode");
};
export default class LabsUserSettingsTab extends React.Component<{}> {
private readonly labs: string[];
private readonly betas: string[];
public constructor(props: {}) {
super(props);
const features = SettingsStore.getFeatureSettingNames();
const [labs, betas] = features.reduce(
(arr, f) => {
arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f);
return arr;
},
[[], []] as [string[], string[]],
);
this.labs = labs;
this.betas = betas;
if (!showLabsFlags()) {
this.labs = [];
}
}
public render(): React.ReactNode {
let betaSection: JSX.Element | undefined;
if (this.betas.length) {
betaSection = (
<>
{this.betas.map((f) => (
<BetaCard key={f} featureId={f} />
))}
</>
);
}
let labsSections: JSX.Element | undefined;
if (this.labs.length) {
const groups = new EnhancedMap<LabGroup, JSX.Element[]>();
this.labs.forEach((f) => {
groups
.getOrCreate(SettingsStore.getLabGroup(f)!, [])
.push(<SettingsFlag level={SettingLevel.DEVICE} name={f} key={f} />);
});
groups
.getOrCreate(LabGroup.Experimental, [])
.push(<SettingsFlag key="lowBandwidth" name="lowBandwidth" level={SettingLevel.DEVICE} />);
groups
.getOrCreate(LabGroup.Analytics, [])
.push(
<SettingsFlag
key="automaticErrorReporting"
name="automaticErrorReporting"
level={SettingLevel.DEVICE}
/>,
<SettingsFlag
key="automaticDecryptionErrorReporting"
name="automaticDecryptionErrorReporting"
level={SettingLevel.DEVICE}
/>,
);
labsSections = (
<>
{sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => (
<SettingsSubsection
key={group}
data-testid={`labs-group-${group}`}
heading={_t(labGroupNames[group])}
>
{flags}
</SettingsSubsection>
))}
</>
);
}
return (
<SettingsTab>
<SettingsSection heading={_t("labs|beta_section")}>
<SettingsSubsectionText>
{_t("labs|beta_description", { brand: SdkConfig.get("brand") })}
</SettingsSubsectionText>
{betaSection}
</SettingsSection>
{labsSections && (
<SettingsSection heading={_t("labs|experimental_section")}>
<SettingsSubsectionText>
{_t(
"labs|experimental_description",
{},
{
a: (sub) => {
return (
<a
href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
rel="noreferrer noopener"
target="_blank"
>
{sub}
</a>
);
},
},
)}
</SettingsSubsectionText>
{labsSections}
</SettingsSection>
)}
</SettingsTab>
);
}
}

View file

@ -0,0 +1,312 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
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, SyntheticEvent } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import { Mjolnir } from "../../../../../mjolnir/Mjolnir";
import { ListRule } from "../../../../../mjolnir/ListRule";
import { BanList, RULE_SERVER, RULE_USER } from "../../../../../mjolnir/BanList";
import Modal from "../../../../../Modal";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import AccessibleButton from "../../../elements/AccessibleButton";
import Field from "../../../elements/Field";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
interface IState {
busy: boolean;
newPersonalRule: string;
newList: string;
}
export default class MjolnirUserSettingsTab extends React.Component<{}, IState> {
public constructor(props: {}) {
super(props);
this.state = {
busy: false,
newPersonalRule: "",
newList: "",
};
}
private onPersonalRuleChanged = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({ newPersonalRule: e.target.value });
};
private onNewListChanged = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({ newList: e.target.value });
};
private onAddPersonalRule = async (e: SyntheticEvent): Promise<void> => {
e.preventDefault();
e.stopPropagation();
let kind = RULE_SERVER;
if (this.state.newPersonalRule.startsWith("@")) {
kind = RULE_USER;
}
this.setState({ busy: true });
try {
const list = await Mjolnir.sharedInstance().getOrCreatePersonalList();
await list.banEntity(kind, this.state.newPersonalRule, _t("labs_mjolnir|ban_reason"));
this.setState({ newPersonalRule: "" }); // this will also cause the new rule to be rendered
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("labs_mjolnir|error_adding_ignore"),
description: _t("labs_mjolnir|something_went_wrong"),
});
} finally {
this.setState({ busy: false });
}
};
private onSubscribeList = async (e: SyntheticEvent): Promise<void> => {
e.preventDefault();
e.stopPropagation();
this.setState({ busy: true });
try {
const room = await MatrixClientPeg.safeGet().joinRoom(this.state.newList);
await Mjolnir.sharedInstance().subscribeToList(room.roomId);
this.setState({ newList: "" }); // this will also cause the new rule to be rendered
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("labs_mjolnir|error_adding_list_title"),
description: _t("labs_mjolnir|error_adding_list_description"),
});
} finally {
this.setState({ busy: false });
}
};
private async removePersonalRule(rule: ListRule): Promise<void> {
this.setState({ busy: true });
try {
const list = Mjolnir.sharedInstance().getPersonalList();
await list!.unbanEntity(rule.kind, rule.entity);
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("labs_mjolnir|error_removing_ignore"),
description: _t("labs_mjolnir|something_went_wrong"),
});
} finally {
this.setState({ busy: false });
}
}
private async unsubscribeFromList(list: BanList): Promise<void> {
this.setState({ busy: true });
try {
await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId);
await MatrixClientPeg.safeGet().leave(list.roomId);
} catch (e) {
logger.error(e);
Modal.createDialog(ErrorDialog, {
title: _t("labs_mjolnir|error_removing_list_title"),
description: _t("labs_mjolnir|error_removing_list_description"),
});
} finally {
this.setState({ busy: false });
}
}
private viewListRules(list: BanList): void {
const room = MatrixClientPeg.safeGet().getRoom(list.roomId);
const name = room ? room.name : list.roomId;
const renderRules = (rules: ListRule[]): JSX.Element => {
if (rules.length === 0) return <i>{_t("labs_mjolnir|rules_empty")}</i>;
const tiles: JSX.Element[] = [];
for (const rule of rules) {
tiles.push(
<li key={rule.kind + rule.entity}>
<code>{rule.entity}</code>
</li>,
);
}
return <ul>{tiles}</ul>;
};
Modal.createDialog(QuestionDialog, {
title: _t("labs_mjolnir|rules_title", { roomName: name }),
description: (
<div>
<h3>{_t("labs_mjolnir|rules_server")}</h3>
{renderRules(list.serverRules)}
<h3>{_t("labs_mjolnir|rules_user")}</h3>
{renderRules(list.userRules)}
</div>
),
button: _t("action|close"),
hasCancelButton: false,
});
}
private renderPersonalBanListRules(): JSX.Element {
const list = Mjolnir.sharedInstance().getPersonalList();
const rules = list ? [...list.userRules, ...list.serverRules] : [];
if (!list || rules.length <= 0) return <i>{_t("labs_mjolnir|personal_empty")}</i>;
const tiles: JSX.Element[] = [];
for (const rule of rules) {
tiles.push(
<li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this.removePersonalRule(rule)}
disabled={this.state.busy}
>
{_t("action|remove")}
</AccessibleButton>
&nbsp;
<code>{rule.entity}</code>
</li>,
);
}
return (
<div>
<p>{_t("labs_mjolnir|personal_section")}</p>
<ul>{tiles}</ul>
</div>
);
}
private renderSubscribedBanLists(): JSX.Element {
const personalList = Mjolnir.sharedInstance().getPersonalList();
const lists = Mjolnir.sharedInstance().lists.filter((b) => {
return personalList ? personalList.roomId !== b.roomId : true;
});
if (!lists || lists.length <= 0) return <i>{_t("labs_mjolnir|no_lists")}</i>;
const tiles: JSX.Element[] = [];
for (const list of lists) {
const room = MatrixClientPeg.safeGet().getRoom(list.roomId);
const name = room ? (
<span>
{room.name} (<code>{list.roomId}</code>)
</span>
) : (
<code>list.roomId</code>
);
tiles.push(
<li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this.unsubscribeFromList(list)}
disabled={this.state.busy}
>
{_t("action|unsubscribe")}
</AccessibleButton>
&nbsp;
<AccessibleButton
kind="primary_sm"
onClick={() => this.viewListRules(list)}
disabled={this.state.busy}
>
{_t("labs_mjolnir|view_rules")}
</AccessibleButton>
&nbsp;
{name}
</li>,
);
}
return (
<div>
<p>{_t("labs_mjolnir|lists")}</p>
<ul>{tiles}</ul>
</div>
);
}
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
return (
<SettingsTab>
<SettingsSection>
<SettingsSubsectionText>
<strong className="warning">{_t("labs_mjolnir|advanced_warning")}</strong>
<p>{_t("labs_mjolnir|explainer_1", { brand }, { code: (s) => <code>{s}</code> })}</p>
<p>{_t("labs_mjolnir|explainer_2")}</p>
</SettingsSubsectionText>
<SettingsSubsection
heading={_t("labs_mjolnir|personal_heading")}
description={_t("labs_mjolnir|personal_description", {
myBanList: _t("labs_mjolnir|room_name"),
})}
>
{this.renderPersonalBanListRules()}
<form onSubmit={this.onAddPersonalRule} autoComplete="off">
<Field
type="text"
label={_t("labs_mjolnir|personal_new_label")}
placeholder={_t("labs_mjolnir|personal_new_placeholder")}
value={this.state.newPersonalRule}
onChange={this.onPersonalRuleChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this.onAddPersonalRule}
disabled={this.state.busy}
>
{_t("action|ignore")}
</AccessibleButton>
</form>
</SettingsSubsection>
<SettingsSubsection
heading={_t("labs_mjolnir|lists_heading")}
description={
<>
<strong className="warning">{_t("labs_mjolnir|lists_description_1")}</strong>
&nbsp;
<span>{_t("labs_mjolnir|lists_description_2")}</span>
</>
}
>
{this.renderSubscribedBanLists()}
<form onSubmit={this.onSubscribeList} autoComplete="off">
<Field
type="text"
label={_t("labs_mjolnir|lists_new_label")}
value={this.state.newList}
onChange={this.onNewListChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this.onSubscribeList}
disabled={this.state.busy}
>
{_t("action|subscribe")}
</AccessibleButton>
</form>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,34 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
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 { Features } from "../../../../../settings/Settings";
import SettingsStore from "../../../../../settings/SettingsStore";
import Notifications from "../../Notifications";
import NotificationSettings2 from "../../notifications/NotificationSettings2";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsTab from "../SettingsTab";
export default class NotificationUserSettingsTab extends React.Component {
public render(): React.ReactNode {
const newNotificationSettingsEnabled = SettingsStore.getValue(Features.NotificationSettings2);
return (
<SettingsTab>
{newNotificationSettingsEnabled ? (
<NotificationSettings2 />
) : (
<SettingsSection>
<Notifications />
</SettingsSection>
)}
</SettingsTab>
);
}
}

View file

@ -0,0 +1,374 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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, useCallback, useEffect, useState } from "react";
import { NonEmptyArray } from "../../../../../@types/common";
import { _t, getCurrentLanguage } from "../../../../../languageHandler";
import { UseCase } from "../../../../../settings/enums/UseCase";
import SettingsStore from "../../../../../settings/SettingsStore";
import Field from "../../../elements/Field";
import Dropdown from "../../../elements/Dropdown";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import SettingsFlag from "../../../elements/SettingsFlag";
import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher";
import { UserTab } from "../../../dialogs/UserTab";
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../../../dispatcher/actions";
import SdkConfig from "../../../../../SdkConfig";
import { showUserOnboardingPage } from "../../../user-onboarding/UserOnboardingPage";
import SettingsSubsection from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import LanguageDropdown from "../../../elements/LanguageDropdown";
import PlatformPeg from "../../../../../PlatformPeg";
import { IS_MAC } from "../../../../../Keyboard";
import SpellCheckSettings from "../../SpellCheckSettings";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as TimezoneHandler from "../../../../../TimezoneHandler";
interface IProps {
closeSettingsFn(success: boolean): void;
}
interface IState {
timezone: string | undefined;
timezones: string[];
timezoneSearch: string | undefined;
autocompleteDelay: string;
readMarkerInViewThresholdMs: string;
readMarkerOutOfViewThresholdMs: string;
}
const LanguageSection: React.FC = () => {
const [language, setLanguage] = useState(getCurrentLanguage());
const onLanguageChange = useCallback(
(newLanguage: string) => {
if (language === newLanguage) return;
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
setLanguage(newLanguage);
const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage([newLanguage]);
platform.reload();
}
},
[language],
);
return (
<div className="mx_SettingsSubsection_dropdown">
{_t("settings|general|application_language")}
<LanguageDropdown onOptionChange={onLanguageChange} value={language} />
<div className="mx_PreferencesUserSettingsTab_section_hint">
{_t("settings|general|application_language_reload_hint")}
</div>
</div>
);
};
const SpellCheckSection: React.FC = () => {
const [spellCheckEnabled, setSpellCheckEnabled] = useState<boolean | undefined>();
const [spellCheckLanguages, setSpellCheckLanguages] = useState<string[] | undefined>();
useEffect(() => {
(async () => {
const plaf = PlatformPeg.get();
const [enabled, langs] = await Promise.all([plaf?.getSpellCheckEnabled(), plaf?.getSpellCheckLanguages()]);
setSpellCheckEnabled(enabled);
setSpellCheckLanguages(langs || undefined);
})();
}, []);
const onSpellCheckEnabledChange = useCallback((enabled: boolean) => {
setSpellCheckEnabled(enabled);
PlatformPeg.get()?.setSpellCheckEnabled(enabled);
}, []);
const onSpellCheckLanguagesChange = useCallback((languages: string[]): void => {
setSpellCheckLanguages(languages);
PlatformPeg.get()?.setSpellCheckLanguages(languages);
}, []);
if (!PlatformPeg.get()?.supportsSpellCheckSettings()) return null;
return (
<>
<LabelledToggleSwitch
label={_t("settings|general|allow_spellcheck")}
value={Boolean(spellCheckEnabled)}
onChange={onSpellCheckEnabledChange}
/>
{spellCheckEnabled && spellCheckLanguages !== undefined && !IS_MAC && (
<SpellCheckSettings languages={spellCheckLanguages} onLanguagesChange={onSpellCheckLanguagesChange} />
)}
</>
);
};
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
private static ROOM_LIST_SETTINGS = ["breadcrumbs", "FTUE.userOnboardingButton"];
private static SPACES_SETTINGS = ["Spaces.allRoomsInHome"];
private static KEYBINDINGS_SETTINGS = ["ctrlFForSearch"];
private static PRESENCE_SETTINGS = ["sendReadReceipts", "sendTypingNotifications"];
private static COMPOSER_SETTINGS = [
"MessageComposerInput.autoReplaceEmoji",
"MessageComposerInput.useMarkdown",
"MessageComposerInput.suggestEmoji",
"MessageComposerInput.ctrlEnterToSend",
"MessageComposerInput.surroundWith",
"MessageComposerInput.showStickersButton",
"MessageComposerInput.insertTrailingColon",
];
private static TIME_SETTINGS = ["showTwelveHourTimestamps", "alwaysShowTimestamps"];
private static CODE_BLOCKS_SETTINGS = [
"enableSyntaxHighlightLanguageDetection",
"expandCodeByDefault",
"showCodeLineNumbers",
];
private static IMAGES_AND_VIDEOS_SETTINGS = ["urlPreviewsEnabled", "autoplayGifs", "autoplayVideo", "showImages"];
private static TIMELINE_SETTINGS = [
"showTypingNotifications",
"showRedactions",
"showReadReceipts",
"showJoinLeaves",
"showDisplaynameChanges",
"showChatEffects",
"showAvatarChanges",
"Pill.shouldShowPillAvatar",
"TextualBody.enableBigEmoji",
"scrollToBottomOnMessageSent",
"useOnlyCurrentProfiles",
];
private static ROOM_DIRECTORY_SETTINGS = ["SpotlightSearch.showNsfwPublicRooms"];
private static GENERAL_SETTINGS = [
"promptBeforeInviteUnknownUsers",
// Start automatically after startup (electron-only)
// Autocomplete delay (niche text box)
];
public constructor(props: IProps) {
super(props);
this.state = {
timezone: TimezoneHandler.getUserTimezone(),
timezones: TimezoneHandler.getAllTimezones(),
timezoneSearch: undefined,
autocompleteDelay: SettingsStore.getValueAt(SettingLevel.DEVICE, "autocompleteDelay").toString(10),
readMarkerInViewThresholdMs: SettingsStore.getValueAt(
SettingLevel.DEVICE,
"readMarkerInViewThresholdMs",
).toString(10),
readMarkerOutOfViewThresholdMs: SettingsStore.getValueAt(
SettingLevel.DEVICE,
"readMarkerOutOfViewThresholdMs",
).toString(10),
};
}
private onTimezoneChange = (tz: string): void => {
this.setState({ timezone: tz });
TimezoneHandler.setUserTimezone(tz);
};
/**
* If present filter the time zones matching the search term
*/
private onTimezoneSearchChange = (search: string): void => {
const timezoneSearch = search.toLowerCase();
const timezones = timezoneSearch
? TimezoneHandler.getAllTimezones().filter((tz) => {
return tz.toLowerCase().includes(timezoneSearch);
})
: TimezoneHandler.getAllTimezones();
this.setState({ timezones, timezoneSearch });
};
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ autocompleteDelay: e.target.value });
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
};
private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ readMarkerInViewThresholdMs: e.target.value });
SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ readMarkerOutOfViewThresholdMs: e.target.value });
SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value);
};
private renderGroup(settingIds: string[], level = SettingLevel.ACCOUNT): React.ReactNodeArray {
return settingIds.map((i) => <SettingsFlag key={i} name={i} level={level} />);
}
private onKeyboardShortcutsClicked = (): void => {
dis.dispatch<OpenToTabPayload>({
action: Action.ViewUserSettings,
initialTabId: UserTab.Keyboard,
});
};
public render(): React.ReactNode {
const useCase = SettingsStore.getValue<UseCase | null>("FTUE.useCaseSelection");
const roomListSettings = PreferencesUserSettingsTab.ROOM_LIST_SETTINGS
// Only show the user onboarding setting if the user should see the user onboarding page
.filter((it) => it !== "FTUE.userOnboardingButton" || showUserOnboardingPage(useCase));
const browserTimezoneLabel: string = _t("settings|preferences|default_timezone", {
timezone: TimezoneHandler.shortBrowserTimezone(),
});
// Always Preprend the default option
const timezones = this.state.timezones.map((tz) => {
return <div key={tz}>{tz}</div>;
});
timezones.unshift(<div key="">{browserTimezoneLabel}</div>);
return (
<SettingsTab data-testid="mx_PreferencesUserSettingsTab">
<SettingsSection>
{/* The heading string is still 'general' from where it was moved, but this section should become 'general' */}
<SettingsSubsection heading={_t("settings|general|language_section")}>
<LanguageSection />
<SpellCheckSection />
</SettingsSubsection>
{roomListSettings.length > 0 && (
<SettingsSubsection heading={_t("settings|preferences|room_list_heading")}>
{this.renderGroup(roomListSettings)}
</SettingsSubsection>
)}
<SettingsSubsection heading={_t("common|spaces")}>
{this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS, SettingLevel.ACCOUNT)}
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|preferences|keyboard_heading")}
description={_t(
"settings|preferences|keyboard_view_shortcuts_button",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onKeyboardShortcutsClicked}>
{sub}
</AccessibleButton>
),
},
)}
>
{this.renderGroup(PreferencesUserSettingsTab.KEYBINDINGS_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|time_heading")}>
<div className="mx_SettingsSubsection_dropdown">
{_t("settings|preferences|user_timezone")}
<Dropdown
id="mx_dropdownUserTimezone"
className="mx_dropdownUserTimezone"
data-testid="mx_dropdownUserTimezone"
searchEnabled={true}
value={this.state.timezone}
label={_t("settings|preferences|user_timezone")}
placeholder={browserTimezoneLabel}
onOptionChange={this.onTimezoneChange}
onSearchChange={this.onTimezoneSearchChange}
>
{timezones as NonEmptyArray<ReactElement & { key: string }>}
</Dropdown>
</div>
{this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)}
<SettingsFlag name="userTimezonePublish" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<SettingsSubsection
heading={_t("common|presence")}
description={_t("settings|preferences|presence_description")}
>
{this.renderGroup(PreferencesUserSettingsTab.PRESENCE_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|composer_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|code_blocks_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|media_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("common|timeline")}>
{this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}>
{this.renderGroup(PreferencesUserSettingsTab.ROOM_DIRECTORY_SETTINGS)}
</SettingsSubsection>
<SettingsSubsection heading={_t("common|general")} stretchContent>
{this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)}
<SettingsFlag name="Electron.showTrayIcon" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag
name="Electron.enableHardwareAcceleration"
level={SettingLevel.PLATFORM}
hideIfCannotSet
label={_t("settings|preferences|Electron.enableHardwareAcceleration", {
appName: SdkConfig.get().brand,
})}
/>
<SettingsFlag name="Electron.alwaysShowMenuBar" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.autoLaunch" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.warnBeforeExit" level={SettingLevel.PLATFORM} hideIfCannotSet />
<Field
label={_t("settings|preferences|autocomplete_delay")}
type="number"
value={this.state.autocompleteDelay}
onChange={this.onAutocompleteDelayChange}
/>
<Field
label={_t("settings|preferences|rm_lifetime")}
type="number"
value={this.state.readMarkerInViewThresholdMs}
onChange={this.onReadMarkerInViewThresholdMs}
/>
<Field
label={_t("settings|preferences|rm_lifetime_offscreen")}
type="number"
value={this.state.readMarkerOutOfViewThresholdMs}
onChange={this.onReadMarkerOutOfViewThresholdMs}
/>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}

View file

@ -0,0 +1,381 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
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 { sleep } from "matrix-js-sdk/src/utils";
import { Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership, Membership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler";
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 { 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";
import { showDialog as showAnalyticsLearnMoreDialog } from "../../../dialogs/AnalyticsLearnMoreDialog";
import { privateShouldBeEncrypted } from "../../../../../utils/rooms";
import type { IServerVersions } from "matrix-js-sdk/src/matrix";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
import { useOwnDevices } from "../../devices/useOwnDevices";
import DiscoverySettings from "../../discovery/DiscoverySettings";
import SetIntegrationManager from "../../SetIntegrationManager";
interface IIgnoredUserProps {
userId: string;
onUnignored: (userId: string) => void;
inProgress: boolean;
}
const DehydratedDeviceStatus: React.FC = () => {
const { dehydratedDeviceId } = useOwnDevices();
if (dehydratedDeviceId) {
return (
<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;
}
};
export class IgnoredUser extends React.Component<IIgnoredUserProps> {
private onUnignoreClicked = (): void => {
this.props.onUnignored(this.props.userId);
};
public render(): React.ReactNode {
const id = `mx_SecurityUserSettingsTab_ignoredUser_${this.props.userId}`;
return (
<div className="mx_SecurityUserSettingsTab_ignoredUser">
<AccessibleButton
onClick={this.onUnignoreClicked}
kind="primary_sm"
aria-describedby={id}
disabled={this.props.inProgress}
>
{_t("action|unignore")}
</AccessibleButton>
<span id={id}>{this.props.userId}</span>
</div>
);
}
}
interface IProps {
closeSettingsFn: () => void;
}
interface IState {
ignoredUserIds: string[];
waitingUnignored: string[];
managingInvites: boolean;
invitedRoomIds: Set<string>;
versions?: IServerVersions;
}
export default class SecurityUserSettingsTab extends React.Component<IProps, IState> {
private dispatcherRef?: string;
public constructor(props: IProps) {
super(props);
// Get rooms we're invited to
const invitedRoomIds = new Set(this.getInvitedRooms().map((room) => room.roomId));
this.state = {
ignoredUserIds: MatrixClientPeg.safeGet().getIgnoredUsers(),
waitingUnignored: [],
managingInvites: false,
invitedRoomIds,
};
}
private onAction = ({ action }: ActionPayload): void => {
if (action === "ignore_state_changed") {
const ignoredUserIds = MatrixClientPeg.safeGet().getIgnoredUsers();
const newWaitingUnignored = this.state.waitingUnignored.filter((e) => ignoredUserIds.includes(e));
this.setState({ ignoredUserIds, waitingUnignored: newWaitingUnignored });
}
};
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.safeGet().on(RoomEvent.MyMembership, this.onMyMembership);
MatrixClientPeg.safeGet()
.getVersions()
.then((versions) => this.setState({ versions }));
}
public componentWillUnmount(): void {
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
MatrixClientPeg.safeGet().removeListener(RoomEvent.MyMembership, this.onMyMembership);
}
private onMyMembership = (room: Room, membership: Membership): void => {
if (room.isSpaceRoom()) {
return;
}
if (membership === KnownMembership.Invite) {
this.addInvitedRoom(room);
} else if (this.state.invitedRoomIds.has(room.roomId)) {
// The user isn't invited anymore
this.removeInvitedRoom(room.roomId);
}
};
private addInvitedRoom = (room: Room): void => {
this.setState(({ invitedRoomIds }) => ({
invitedRoomIds: new Set(invitedRoomIds).add(room.roomId),
}));
};
private removeInvitedRoom = (roomId: string): void => {
this.setState(({ invitedRoomIds }) => {
const newInvitedRoomIds = new Set(invitedRoomIds);
newInvitedRoomIds.delete(roomId);
return {
invitedRoomIds: newInvitedRoomIds,
};
});
};
private onUserUnignored = async (userId: string): Promise<void> => {
const { ignoredUserIds, waitingUnignored } = this.state;
const currentlyIgnoredUserIds = ignoredUserIds.filter((e) => !waitingUnignored.includes(e));
const index = currentlyIgnoredUserIds.indexOf(userId);
if (index !== -1) {
currentlyIgnoredUserIds.splice(index, 1);
this.setState(({ waitingUnignored }) => ({ waitingUnignored: [...waitingUnignored, userId] }));
MatrixClientPeg.safeGet().setIgnoredUsers(currentlyIgnoredUserIds);
}
};
private getInvitedRooms = (): Room[] => {
return MatrixClientPeg.safeGet()
.getRooms()
.filter((r) => {
return r.hasMembershipState(MatrixClientPeg.safeGet().getUserId()!, KnownMembership.Invite);
});
};
private manageInvites = async (accept: boolean): Promise<void> => {
this.setState({
managingInvites: true,
});
// iterate with a normal for loop in order to retry on action failure
const invitedRoomIdsValues = Array.from(this.state.invitedRoomIds);
// Execute all acceptances/rejections sequentially
const cli = MatrixClientPeg.safeGet();
const action = accept ? cli.joinRoom.bind(cli) : cli.leave.bind(cli);
for (let i = 0; i < invitedRoomIdsValues.length; i++) {
const roomId = invitedRoomIdsValues[i];
// Accept/reject invite
await action(roomId).then(
() => {
// No error, update invited rooms button
this.removeInvitedRoom(roomId);
},
async (e): Promise<void> => {
// Action failure
if (e.errcode === "M_LIMIT_EXCEEDED") {
// Add a delay between each invite change in order to avoid rate
// limiting by the server.
await sleep(e.retry_after_ms || 2500);
// Redo last action
i--;
} else {
// Print out error with joining/leaving room
logger.warn(e);
}
},
);
}
this.setState({
managingInvites: false,
});
};
private onAcceptAllInvitesClicked = (): void => {
this.manageInvites(true);
};
private onRejectAllInvitesClicked = (): void => {
this.manageInvites(false);
};
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)}
/>
);
});
return (
<SettingsSubsection heading={_t("settings|security|ignore_users_section")}>
<SettingsSubsectionText>{userIds}</SettingsSubsectionText>
</SettingsSubsection>
);
}
private renderManageInvites(): ReactNode {
const { invitedRoomIds } = this.state;
if (invitedRoomIds.size === 0) {
return null;
}
return (
<SettingsSubsection heading={_t("settings|security|bulk_options_section")}>
<div className="mx_SecurityUserSettingsTab_bulkOptions">
<AccessibleButton
onClick={this.onAcceptAllInvitesClicked}
kind="primary_outline"
disabled={this.state.managingInvites}
>
{_t("settings|security|bulk_options_accept_all_invites", { invitedRooms: invitedRoomIds.size })}
</AccessibleButton>
<AccessibleButton
onClick={this.onRejectAllInvitesClicked}
kind="danger_outline"
disabled={this.state.managingInvites}
>
{_t("settings|security|bulk_options_reject_all_invites", { invitedRooms: invitedRoomIds.size })}
</AccessibleButton>
{this.state.managingInvites ? <InlineSpinner /> : <div />}
</div>
</SettingsSubsection>
);
}
public render(): React.ReactNode {
const secureBackup = (
<SettingsSubsection heading={_t("common|secure_backup")}>
<SecureBackupPanel />
<DehydratedDeviceStatus />
</SettingsSubsection>
);
const eventIndex = (
<SettingsSubsection heading={_t("settings|security|message_search_section")}>
<EventIndexPanel />
</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 = (
<div className="mx_SecurityUserSettingsTab_warning">
{_t("settings|security|e2ee_default_disabled_warning")}
</div>
);
}
let privacySection;
if (PosthogAnalytics.instance.isEnabled()) {
const onClickAnalyticsLearnMore = (): void => {
showAnalyticsLearnMoreDialog({
primaryButton: _t("action|ok"),
hasCancel: false,
});
};
privacySection = (
<SettingsSection heading={_t("common|privacy")}>
<DiscoverySettings />
<SettingsSubsection
heading={_t("common|analytics")}
description={_t("settings|security|analytics_description")}
>
<AccessibleButton kind="link" onClick={onClickAnalyticsLearnMore}>
{_t("action|learn_more")}
</AccessibleButton>
{PosthogAnalytics.instance.isEnabled() && (
<SettingsFlag name="pseudonymousAnalyticsOptIn" level={SettingLevel.ACCOUNT} />
)}
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|sessions|title")}>
<SettingsFlag name="deviceClientInformationOptIn" level={SettingLevel.ACCOUNT} />
</SettingsSubsection>
</SettingsSection>
);
}
let advancedSection;
if (SettingsStore.getValue(UIFeature.AdvancedSettings)) {
const ignoreUsersPanel = this.renderIgnoredUsers();
const invitesPanel = this.renderManageInvites();
// only show the section if there's something to show
if (ignoreUsersPanel || invitesPanel) {
advancedSection = (
<SettingsSection heading={_t("common|advanced")}>
{ignoreUsersPanel}
{invitesPanel}
</SettingsSection>
);
}
}
return (
<SettingsTab>
{warning}
<SetIntegrationManager />
<SettingsSection heading={_t("settings|security|encryption_section")}>
{secureBackup}
{eventIndex}
{crossSigning}
<CryptographyPanel />
</SettingsSection>
{privacySection}
{advancedSection}
</SettingsTab>
);
}
}

View file

@ -0,0 +1,374 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { discoverAndValidateOIDCIssuerWellKnown, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import SettingsSubsection from "../../shared/SettingsSubsection";
import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog";
import VerificationRequestDialog from "../../../dialogs/VerificationRequestDialog";
import LogoutDialog from "../../../dialogs/LogoutDialog";
import { useOwnDevices } from "../../devices/useOwnDevices";
import { FilteredDeviceList } from "../../devices/FilteredDeviceList";
import CurrentDeviceSection from "../../devices/CurrentDeviceSection";
import SecurityRecommendations from "../../devices/SecurityRecommendations";
import { ExtendedDevice } from "../../devices/types";
import { deleteDevicesWithInteractiveAuth } from "../../devices/deleteDevices";
import SettingsTab from "../SettingsTab";
import LoginWithQRSection from "../../devices/LoginWithQRSection";
import { Mode } from "../../../auth/LoginWithQR-types";
import { useAsyncMemo } from "../../../../../hooks/useAsyncMemo";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import { FilterVariation } from "../../devices/filter";
import { OtherSessionsSectionHeading } from "../../devices/OtherSessionsSectionHeading";
import { SettingsSection } from "../../shared/SettingsSection";
import { OidcLogoutDialog } from "../../../dialogs/oidc/OidcLogoutDialog";
import { SDKContext } from "../../../../../contexts/SDKContext";
import Spinner from "../../../elements/Spinner";
// We import `LoginWithQR` asynchronously to avoid importing the entire Rust Crypto WASM into the main bundle.
const LoginWithQR = lazy(() => import("../../../auth/LoginWithQR"));
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("action|sign_out"),
description: (
<div>
<p>
{_t("settings|sessions|sign_out_confirm_description", {
count: sessionsToSignOutCount,
})}
</p>
</div>
),
cancelButton: _t("action|cancel"),
button: _t("action|sign_out"),
});
const [confirmed] = await finished;
return !!confirmed;
};
const confirmDelegatedAuthSignOut = async (delegatedAuthAccountUrl: string, deviceId: string): Promise<boolean> => {
const { finished } = Modal.createDialog(OidcLogoutDialog, {
deviceId,
delegatedAuthAccountUrl,
});
const [confirmed] = await finished;
return !!confirmed;
};
const useSignOut = (
matrixClient: MatrixClient,
onSignoutResolvedCallback: () => Promise<void>,
delegatedAuthAccountUrl?: string,
): {
onSignOutCurrentDevice: () => void;
onSignOutOtherDevices: (deviceIds: ExtendedDevice["device_id"][]) => Promise<void>;
signingOutDeviceIds: ExtendedDevice["device_id"][];
} => {
const [signingOutDeviceIds, setSigningOutDeviceIds] = useState<ExtendedDevice["device_id"][]>([]);
const onSignOutCurrentDevice = (): void => {
Modal.createDialog(
LogoutDialog,
{}, // props,
undefined, // className
false, // isPriority
true, // isStatic
);
};
const onSignOutOtherDevices = async (deviceIds: ExtendedDevice["device_id"][]): Promise<void> => {
if (!deviceIds.length) {
return;
}
// we can only sign out exactly one OIDC-aware device at a time
// we should not encounter this
if (delegatedAuthAccountUrl && deviceIds.length !== 1) {
logger.warn("Unexpectedly tried to sign out multiple OIDC-aware devices.");
return;
}
// delegated auth logout flow confirms and signs out together
// so only confirm if we are NOT doing a delegated auth sign out
if (!delegatedAuthAccountUrl) {
const userConfirmedSignout = await confirmSignOut(deviceIds.length);
if (!userConfirmedSignout) {
return;
}
}
try {
setSigningOutDeviceIds([...signingOutDeviceIds, ...deviceIds]);
const onSignOutFinished = async (success: boolean): Promise<void> => {
if (success) {
await onSignoutResolvedCallback();
}
setSigningOutDeviceIds(signingOutDeviceIds.filter((deviceId) => !deviceIds.includes(deviceId)));
};
if (delegatedAuthAccountUrl) {
const [deviceId] = deviceIds;
try {
setSigningOutDeviceIds([...signingOutDeviceIds, deviceId]);
const success = await confirmDelegatedAuthSignOut(delegatedAuthAccountUrl, deviceId);
await onSignOutFinished(success);
} catch (error) {
logger.error("Error deleting OIDC-aware sessions", error);
}
} else {
await deleteDevicesWithInteractiveAuth(matrixClient, deviceIds, onSignOutFinished);
}
} catch (error) {
logger.error("Error deleting sessions", error);
setSigningOutDeviceIds(signingOutDeviceIds.filter((deviceId) => !deviceIds.includes(deviceId)));
}
};
return {
onSignOutCurrentDevice,
onSignOutOtherDevices,
signingOutDeviceIds,
};
};
const SessionManagerTab: React.FC<{
showMsc4108QrCode?: boolean;
}> = ({ showMsc4108QrCode }) => {
const {
devices,
dehydratedDeviceId,
pushers,
localNotificationSettings,
currentDeviceId,
isLoadingDeviceList,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPushNotifications,
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<FilterVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<ExtendedDevice["device_id"][]>([]);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<ExtendedDevice["device_id"][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<number>();
const sdkContext = useContext(SDKContext);
const matrixClient = sdkContext.client!;
/**
* If we have a delegated auth account management URL, all sessions but the current session need to be managed in the
* delegated auth provider.
* See https://github.com/matrix-org/matrix-spec-proposals/pull/3824
*/
const delegatedAuthAccountUrl = useAsyncMemo(async () => {
await sdkContext.oidcClientStore.readyPromise; // wait for the store to be ready
return sdkContext.oidcClientStore.accountManagementEndpoint;
}, [sdkContext.oidcClientStore]);
const disableMultipleSignout = !!delegatedAuthAccountUrl;
const userId = matrixClient?.getUserId();
const currentUserMember = (userId && matrixClient?.getUser(userId)) || undefined;
const clientVersions = useAsyncMemo(() => matrixClient.getVersions(), [matrixClient]);
const capabilities = useAsyncMemo(async () => matrixClient?.getCapabilities(), [matrixClient]);
const wellKnown = useMemo(() => matrixClient?.getClientWellKnown(), [matrixClient]);
const oidcClientConfig = useAsyncMemo(async () => {
try {
const authIssuer = await matrixClient?.getAuthIssuer();
if (authIssuer) {
return discoverAndValidateOIDCIssuerWellKnown(authIssuer.issuer);
}
} catch (e) {
logger.error("Failed to discover OIDC metadata", e);
}
}, [matrixClient]);
const isCrossSigningReady = useAsyncMemo(
async () => matrixClient.getCrypto()?.isCrossSigningReady() ?? false,
[matrixClient],
);
const onDeviceExpandToggle = (deviceId: ExtendedDevice["device_id"]): void => {
if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter((id) => id !== deviceId));
} else {
setExpandedDeviceIds([...expandedDeviceIds, deviceId]);
}
};
const onGoToFilteredList = (filter: FilterVariation): void => {
setFilter(filter);
clearTimeout(scrollIntoViewTimeoutRef.current);
// wait a tick for the filtered section to rerender with different height
scrollIntoViewTimeoutRef.current = window.setTimeout(() =>
filteredDeviceListRef.current?.scrollIntoView({
// align element to top of scrollbox
block: "start",
inline: "nearest",
behavior: "smooth",
}),
);
};
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
if (dehydratedDeviceId && otherDevices[dehydratedDeviceId]?.isVerified) {
delete otherDevices[dehydratedDeviceId];
}
const otherSessionsCount = Object.keys(otherDevices).length;
const shouldShowOtherSessions = otherSessionsCount > 0;
const onVerifyCurrentDevice = (): void => {
Modal.createDialog(SetupEncryptionDialog, { onFinished: refreshDevices });
};
const onTriggerDeviceVerification = useCallback(
(deviceId: ExtendedDevice["device_id"]) => {
if (!requestDeviceVerification) {
return;
}
const verificationRequestPromise = requestDeviceVerification(deviceId);
Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise,
member: currentUserMember,
onFinished: async (): Promise<void> => {
const request = await verificationRequestPromise;
request.cancel();
await refreshDevices();
},
});
},
[requestDeviceVerification, refreshDevices, currentUserMember],
);
const onSignoutResolvedCallback = async (): Promise<void> => {
await refreshDevices();
setSelectedDeviceIds([]);
};
const { onSignOutCurrentDevice, onSignOutOtherDevices, signingOutDeviceIds } = useSignOut(
matrixClient,
onSignoutResolvedCallback,
delegatedAuthAccountUrl,
);
useEffect(
() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current);
},
[scrollIntoViewTimeoutRef],
);
// clear selection when filter changes
useEffect(() => {
setSelectedDeviceIds([]);
}, [filter, setSelectedDeviceIds]);
const signOutAllOtherSessions =
shouldShowOtherSessions && !disableMultipleSignout
? () => {
onSignOutOtherDevices(Object.keys(otherDevices));
}
: undefined;
const [signInWithQrMode, setSignInWithQrMode] = useState<Mode | null>(showMsc4108QrCode ? Mode.Show : null);
const onQrFinish = useCallback(() => {
setSignInWithQrMode(null);
}, [setSignInWithQrMode]);
const onShowQrClicked = useCallback(() => {
setSignInWithQrMode(Mode.Show);
}, [setSignInWithQrMode]);
if (signInWithQrMode) {
return (
<Suspense fallback={<Spinner />}>
<LoginWithQR
mode={signInWithQrMode}
onFinished={onQrFinish}
client={matrixClient}
legacy={!oidcClientConfig && !showMsc4108QrCode}
/>
</Suspense>
);
}
return (
<SettingsTab>
<SettingsSection>
<LoginWithQRSection
onShowQr={onShowQrClicked}
versions={clientVersions}
capabilities={capabilities}
wellKnown={wellKnown}
oidcClientConfig={oidcClientConfig}
isCrossSigningReady={isCrossSigningReady}
/>
<SecurityRecommendations
devices={devices}
goToFilteredList={onGoToFilteredList}
currentDeviceId={currentDeviceId}
/>
<CurrentDeviceSection
device={currentDevice}
localNotificationSettings={localNotificationSettings.get(currentDeviceId)}
setPushNotifications={setPushNotifications}
isSigningOut={signingOutDeviceIds.includes(currentDeviceId)}
isLoading={isLoadingDeviceList}
saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)}
onVerifyCurrentDevice={onVerifyCurrentDevice}
onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
otherSessionsCount={otherSessionsCount}
/>
{shouldShowOtherSessions && (
<SettingsSubsection
heading={
<OtherSessionsSectionHeading
otherSessionsCount={otherSessionsCount}
signOutAllOtherSessions={signOutAllOtherSessions}
disabled={!!signingOutDeviceIds.length}
/>
}
description={_t("settings|sessions|best_security_note")}
data-testid="other-sessions-section"
stretchContent
>
<FilteredDeviceList
devices={otherDevices}
pushers={pushers}
localNotificationSettings={localNotificationSettings}
filter={filter}
expandedDeviceIds={expandedDeviceIds}
signingOutDeviceIds={signingOutDeviceIds}
selectedDeviceIds={selectedDeviceIds}
setSelectedDeviceIds={setSelectedDeviceIds}
onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle}
onRequestDeviceVerification={
requestDeviceVerification ? onTriggerDeviceVerification : undefined
}
onSignOutDevices={onSignOutOtherDevices}
saveDeviceName={saveDeviceName}
setPushNotifications={setPushNotifications}
ref={filteredDeviceListRef}
supportsMSC3881={supportsMSC3881}
disableMultipleSignout={disableMultipleSignout}
/>
</SettingsSubsection>
)}
</SettingsSection>
</SettingsTab>
);
};
export default SessionManagerTab;

View file

@ -0,0 +1,173 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
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, useMemo } from "react";
import CameraCircle from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { Icon as HomeIcon } from "../../../../../../res/img/element-icons/home.svg";
import { Icon as FavoriteIcon } from "../../../../../../res/img/element-icons/roomlist/favorite.svg";
import { Icon as MembersIcon } from "../../../../../../res/img/element-icons/room/members.svg";
import { Icon as HashCircleIcon } from "../../../../../../res/img/element-icons/roomlist/hash-circle.svg";
import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import StyledCheckbox from "../../../elements/StyledCheckbox";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { MetaSpace } from "../../../../../stores/spaces";
import PosthogTrackers from "../../../../../PosthogTrackers";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
import SdkConfig from "../../../../../SdkConfig";
type InteractionName = "WebSettingsSidebarTabSpacesCheckbox" | "WebQuickSettingsPinToSidebarCheckbox";
export const onMetaSpaceChangeFactory =
(metaSpace: MetaSpace, interactionName: InteractionName) => async (e: ChangeEvent<HTMLInputElement>) => {
const currentValue = SettingsStore.getValue("Spaces.enabledMetaSpaces");
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.ACCOUNT, {
...currentValue,
[metaSpace]: e.target.checked,
});
PosthogTrackers.trackInteraction(
interactionName,
e,
[
MetaSpace.Home,
null,
MetaSpace.Favourites,
MetaSpace.People,
MetaSpace.Orphans,
MetaSpace.VideoRooms,
].indexOf(metaSpace),
);
};
const SidebarUserSettingsTab: React.FC = () => {
const {
[MetaSpace.Home]: homeEnabled,
[MetaSpace.Favourites]: favouritesEnabled,
[MetaSpace.People]: peopleEnabled,
[MetaSpace.Orphans]: orphansEnabled,
[MetaSpace.VideoRooms]: videoRoomsEnabled,
} = useSettingValue<Record<MetaSpace, boolean>>("Spaces.enabledMetaSpaces");
const allRoomsInHome = useSettingValue<boolean>("Spaces.allRoomsInHome");
const guestSpaUrl = useMemo(() => {
return SdkConfig.get("element_call").guest_spa_url;
}, []);
const conferenceSubsectionText =
_t("settings|sidebar|metaspaces_video_rooms_description") +
(guestSpaUrl ? " " + _t("settings|sidebar|metaspaces_video_rooms_description_invite_extension") : "");
const onAllRoomsInHomeToggle = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, event.target.checked);
PosthogTrackers.trackInteraction("WebSettingsSidebarTabSpacesCheckbox", event, 1);
};
return (
<SettingsTab>
<SettingsSection>
<SettingsSubsection
heading={_t("settings|sidebar|metaspaces_subsection")}
description={_t("settings|sidebar|spaces_explainer")}
>
<StyledCheckbox
checked={!!homeEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Home, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
disabled={homeEnabled}
>
<SettingsSubsectionText>
<HomeIcon />
{_t("common|home")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_home_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={allRoomsInHome}
disabled={!homeEnabled}
onChange={onAllRoomsInHomeToggle}
className="mx_SidebarUserSettingsTab_checkbox mx_SidebarUserSettingsTab_homeAllRoomsCheckbox"
data-testid="mx_SidebarUserSettingsTab_homeAllRoomsCheckbox"
>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_home_all_rooms")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_home_all_rooms_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Favourites, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<FavoriteIcon />
{_t("common|favourites")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_favourites_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.People, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<MembersIcon />
{_t("common|people")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_people_description")}
</SettingsSubsectionText>
</StyledCheckbox>
<StyledCheckbox
checked={!!orphansEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Orphans, "WebSettingsSidebarTabSpacesCheckbox")}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<HashCircleIcon />
{_t("settings|sidebar|metaspaces_orphans")}
</SettingsSubsectionText>
<SettingsSubsectionText>
{_t("settings|sidebar|metaspaces_orphans_description")}
</SettingsSubsectionText>
</StyledCheckbox>
{SettingsStore.getValue("feature_video_rooms") && (
<StyledCheckbox
checked={!!videoRoomsEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.VideoRooms,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
>
<SettingsSubsectionText>
<CameraCircle />
{_t("settings|sidebar|metaspaces_video_rooms")}
</SettingsSubsectionText>
<SettingsSubsectionText>{conferenceSubsectionText}</SettingsSubsectionText>
</StyledCheckbox>
)}
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
};
export default SidebarUserSettingsTab;

View file

@ -0,0 +1,232 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 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, { ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { FALLBACK_ICE_SERVER } from "matrix-js-sdk/src/webrtc/call";
import { _t } from "../../../../../languageHandler";
import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler";
import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import SettingsFlag from "../../../elements/SettingsFlag";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { requestMediaPermissions } from "../../../../../utils/media/requestMediaPermissions";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection from "../../shared/SettingsSubsection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
interface IState {
mediaDevices: IMediaDevices | null;
[MediaDeviceKindEnum.AudioOutput]: string | null;
[MediaDeviceKindEnum.AudioInput]: string | null;
[MediaDeviceKindEnum.VideoInput]: string | null;
audioAutoGainControl: boolean;
audioEchoCancellation: boolean;
audioNoiseSuppression: boolean;
}
/**
* Maps deviceKind to the right get method on MediaDeviceHandler
* Helpful for setting state
*/
const mapDeviceKindToHandlerValue = (deviceKind: MediaDeviceKindEnum): string | null => {
switch (deviceKind) {
case MediaDeviceKindEnum.AudioOutput:
return MediaDeviceHandler.getAudioOutput();
case MediaDeviceKindEnum.AudioInput:
return MediaDeviceHandler.getAudioInput();
case MediaDeviceKindEnum.VideoInput:
return MediaDeviceHandler.getVideoInput();
}
};
export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: {}, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
mediaDevices: null,
[MediaDeviceKindEnum.AudioOutput]: null,
[MediaDeviceKindEnum.AudioInput]: null,
[MediaDeviceKindEnum.VideoInput]: null,
audioAutoGainControl: MediaDeviceHandler.getAudioAutoGainControl(),
audioEchoCancellation: MediaDeviceHandler.getAudioEchoCancellation(),
audioNoiseSuppression: MediaDeviceHandler.getAudioNoiseSuppression(),
};
}
public async componentDidMount(): Promise<void> {
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) {
await this.refreshMediaDevices();
}
}
private refreshMediaDevices = async (stream?: MediaStream): Promise<void> => {
this.setState({
mediaDevices: (await MediaDeviceHandler.getDevices()) ?? null,
[MediaDeviceKindEnum.AudioOutput]: mapDeviceKindToHandlerValue(MediaDeviceKindEnum.AudioOutput),
[MediaDeviceKindEnum.AudioInput]: mapDeviceKindToHandlerValue(MediaDeviceKindEnum.AudioInput),
[MediaDeviceKindEnum.VideoInput]: mapDeviceKindToHandlerValue(MediaDeviceKindEnum.VideoInput),
});
if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
// so that we don't leave it lingering around with webcam enabled etc
// as here we called gUM to ask user for permission to their device names only
stream.getTracks().forEach((track) => track.stop());
}
};
private requestMediaPermissions = async (): Promise<void> => {
const stream = await requestMediaPermissions();
if (stream) {
await this.refreshMediaDevices(stream);
}
};
private setDevice = async (deviceId: string, kind: MediaDeviceKindEnum): Promise<void> => {
// set state immediately so UI is responsive
this.setState<any>({ [kind]: deviceId });
try {
await MediaDeviceHandler.instance.setDevice(deviceId, kind);
} catch (error) {
logger.error(`Failed to set device ${kind}: ${deviceId}`);
// reset state to current value
this.setState<any>({ [kind]: mapDeviceKindToHandlerValue(kind) });
}
};
private changeWebRtcMethod = (p2p: boolean): void => {
this.context.setForceTURN(!p2p);
};
private renderDeviceOptions(devices: Array<MediaDeviceInfo>, category: MediaDeviceKindEnum): Array<JSX.Element> {
return devices.map((d) => {
return (
<option key={`${category}-${d.deviceId}`} value={d.deviceId}>
{d.label}
</option>
);
});
}
private renderDropdown(kind: MediaDeviceKindEnum, label: string): ReactNode {
const devices = this.state.mediaDevices?.[kind].slice(0);
if (!devices?.length) return null;
const defaultDevice = MediaDeviceHandler.getDefaultDevice(devices);
return (
<Field
element="select"
label={label}
value={this.state[kind] || defaultDevice}
onChange={(e) => this.setDevice(e.target.value, kind)}
>
{this.renderDeviceOptions(devices, kind)}
</Field>
);
}
public render(): ReactNode {
let requestButton: ReactNode | undefined;
let speakerDropdown: ReactNode | undefined;
let microphoneDropdown: ReactNode | undefined;
let webcamDropdown: ReactNode | undefined;
if (!this.state.mediaDevices) {
requestButton = (
<div>
<p>{_t("settings|voip|missing_permissions_prompt")}</p>
<AccessibleButton onClick={this.requestMediaPermissions} kind="primary">
{_t("settings|voip|request_permissions")}
</AccessibleButton>
</div>
);
} else if (this.state.mediaDevices) {
speakerDropdown = this.renderDropdown(
MediaDeviceKindEnum.AudioOutput,
_t("settings|voip|audio_output"),
) || <p>{_t("settings|voip|audio_output_empty")}</p>;
microphoneDropdown = this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("common|microphone")) || (
<p>{_t("settings|voip|audio_input_empty")}</p>
);
webcamDropdown = this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("common|camera")) || (
<p>{_t("settings|voip|video_input_empty")}</p>
);
}
return (
<SettingsTab>
<SettingsSection>
{requestButton}
<SettingsSubsection heading={_t("settings|voip|voice_section")} stretchContent>
{speakerDropdown}
{microphoneDropdown}
<LabelledToggleSwitch
value={this.state.audioAutoGainControl}
onChange={async (v): Promise<void> => {
await MediaDeviceHandler.setAudioAutoGainControl(v);
this.setState({ audioAutoGainControl: MediaDeviceHandler.getAudioAutoGainControl() });
}}
label={_t("settings|voip|voice_agc")}
data-testid="voice-auto-gain"
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|voip|video_section")} stretchContent>
{webcamDropdown}
<SettingsFlag name="VideoView.flipVideoHorizontally" level={SettingLevel.ACCOUNT} />
</SettingsSubsection>
</SettingsSection>
<SettingsSection heading={_t("common|advanced")}>
<SettingsSubsection heading={_t("settings|voip|voice_processing")}>
<LabelledToggleSwitch
value={this.state.audioNoiseSuppression}
onChange={async (v): Promise<void> => {
await MediaDeviceHandler.setAudioNoiseSuppression(v);
this.setState({ audioNoiseSuppression: MediaDeviceHandler.getAudioNoiseSuppression() });
}}
label={_t("settings|voip|noise_suppression")}
data-testid="voice-noise-suppression"
/>
<LabelledToggleSwitch
value={this.state.audioEchoCancellation}
onChange={async (v): Promise<void> => {
await MediaDeviceHandler.setAudioEchoCancellation(v);
this.setState({ audioEchoCancellation: MediaDeviceHandler.getAudioEchoCancellation() });
}}
label={_t("settings|voip|echo_cancellation")}
data-testid="voice-echo-cancellation"
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("settings|voip|connection_section")}>
<SettingsFlag
name="webRtcAllowPeerToPeer"
level={SettingLevel.DEVICE}
onChange={this.changeWebRtcMethod}
/>
<SettingsFlag
name="fallbackICEServerAllowed"
label={_t("settings|voip|enable_fallback_ice_server", {
server: new URL(FALLBACK_ICE_SERVER).pathname,
})}
level={SettingLevel.DEVICE}
hideIfCannotSet
/>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
}
}