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:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
123
src/components/views/settings/AddPrivilegedUsers.tsx
Normal file
123
src/components/views/settings/AddPrivilegedUsers.tsx
Normal 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!);
|
||||
};
|
526
src/components/views/settings/AddRemoveThreepids.tsx
Normal file
526
src/components/views/settings/AddRemoveThreepids.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
230
src/components/views/settings/AvatarSetting.tsx
Normal file
230
src/components/views/settings/AvatarSetting.tsx
Normal 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;
|
191
src/components/views/settings/BridgeTile.tsx
Normal file
191
src/components/views/settings/BridgeTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
382
src/components/views/settings/ChangePassword.tsx
Normal file
382
src/components/views/settings/ChangePassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
312
src/components/views/settings/CrossSigningPanel.tsx
Normal file
312
src/components/views/settings/CrossSigningPanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
145
src/components/views/settings/CryptographyPanel.tsx
Normal file
145
src/components/views/settings/CryptographyPanel.tsx
Normal 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);
|
||||
};
|
||||
}
|
242
src/components/views/settings/EventIndexPanel.tsx
Normal file
242
src/components/views/settings/EventIndexPanel.tsx
Normal 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;
|
||||
}
|
||||
}
|
138
src/components/views/settings/FontScalingPanel.tsx
Normal file
138
src/components/views/settings/FontScalingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
73
src/components/views/settings/ImageSizePanel.tsx
Normal file
73
src/components/views/settings/ImageSizePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
101
src/components/views/settings/IntegrationManager.tsx
Normal file
101
src/components/views/settings/IntegrationManager.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
407
src/components/views/settings/JoinRuleSettings.tsx
Normal file
407
src/components/views/settings/JoinRuleSettings.tsx
Normal 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;
|
62
src/components/views/settings/KeyboardShortcut.tsx
Normal file
62
src/components/views/settings/KeyboardShortcut.tsx
Normal 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>
|
||||
);
|
||||
};
|
162
src/components/views/settings/LayoutSwitcher.tsx
Normal file
162
src/components/views/settings/LayoutSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
883
src/components/views/settings/Notifications.tsx
Normal file
883
src/components/views/settings/Notifications.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
140
src/components/views/settings/PowerLevelSelector.tsx
Normal file
140
src/components/views/settings/PowerLevelSelector.tsx
Normal 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());
|
||||
}
|
426
src/components/views/settings/SecureBackupPanel.tsx
Normal file
426
src/components/views/settings/SecureBackupPanel.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
422
src/components/views/settings/SetIdServer.tsx
Normal file
422
src/components/views/settings/SetIdServer.tsx
Normal 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> {_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>
|
||||
);
|
||||
}
|
||||
}
|
92
src/components/views/settings/SetIntegrationManager.tsx
Normal file
92
src/components/views/settings/SetIntegrationManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
32
src/components/views/settings/SettingsFieldset.tsx
Normal file
32
src/components/views/settings/SettingsFieldset.tsx
Normal 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;
|
113
src/components/views/settings/SpellCheckSettings.tsx
Normal file
113
src/components/views/settings/SpellCheckSettings.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
336
src/components/views/settings/ThemeChoicePanel.tsx
Normal file
336
src/components/views/settings/ThemeChoicePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
88
src/components/views/settings/UpdateCheckButton.tsx
Normal file
88
src/components/views/settings/UpdateCheckButton.tsx
Normal 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;
|
129
src/components/views/settings/UserPersonalInfoSettings.tsx
Normal file
129
src/components/views/settings/UserPersonalInfoSettings.tsx
Normal 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;
|
248
src/components/views/settings/UserProfileSettings.tsx
Normal file
248
src/components/views/settings/UserProfileSettings.tsx
Normal 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;
|
146
src/components/views/settings/devices/CurrentDeviceSection.tsx
Normal file
146
src/components/views/settings/devices/CurrentDeviceSection.tsx
Normal 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;
|
130
src/components/views/settings/devices/DeviceDetailHeading.tsx
Normal file
130
src/components/views/settings/devices/DeviceDetailHeading.tsx
Normal 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>
|
||||
);
|
||||
};
|
181
src/components/views/settings/devices/DeviceDetails.tsx
Normal file
181
src/components/views/settings/devices/DeviceDetails.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
};
|
82
src/components/views/settings/devices/DeviceMetaData.tsx
Normal file
82
src/components/views/settings/devices/DeviceMetaData.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
52
src/components/views/settings/devices/DeviceSecurityCard.tsx
Normal file
52
src/components/views/settings/devices/DeviceSecurityCard.tsx
Normal 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;
|
|
@ -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} />;
|
||||
};
|
49
src/components/views/settings/devices/DeviceTile.tsx
Normal file
49
src/components/views/settings/devices/DeviceTile.tsx
Normal 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;
|
68
src/components/views/settings/devices/DeviceTypeIcon.tsx
Normal file
68
src/components/views/settings/devices/DeviceTypeIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
382
src/components/views/settings/devices/FilteredDeviceList.tsx
Normal file
382
src/components/views/settings/devices/FilteredDeviceList.tsx
Normal 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 && (
|
||||
<>
|
||||
|
||||
<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>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -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;
|
100
src/components/views/settings/devices/LoginWithQRSection.tsx
Normal file
100
src/components/views/settings/devices/LoginWithQRSection.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
77
src/components/views/settings/devices/deleteDevices.tsx
Normal file
77
src/components/views/settings/devices/deleteDevices.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
40
src/components/views/settings/devices/filter.ts
Normal file
40
src/components/views/settings/devices/filter.ts
Normal 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)));
|
||||
};
|
36
src/components/views/settings/devices/types.ts
Normal file
36
src/components/views/settings/devices/types.ts
Normal 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",
|
||||
}
|
252
src/components/views/settings/devices/useOwnDevices.ts
Normal file
252
src/components/views/settings/devices/useOwnDevices.ts
Normal 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,
|
||||
};
|
||||
};
|
200
src/components/views/settings/discovery/DiscoverySettings.tsx
Normal file
200
src/components/views/settings/discovery/DiscoverySettings.tsx
Normal 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;
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
31
src/components/views/settings/shared/SettingsBanner.tsx
Normal file
31
src/components/views/settings/shared/SettingsBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
src/components/views/settings/shared/SettingsIndent.tsx
Normal file
19
src/components/views/settings/shared/SettingsIndent.tsx
Normal 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>
|
||||
);
|
56
src/components/views/settings/shared/SettingsSection.tsx
Normal file
56
src/components/views/settings/shared/SettingsSection.tsx
Normal 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>
|
||||
);
|
69
src/components/views/settings/shared/SettingsSubsection.tsx
Normal file
69
src/components/views/settings/shared/SettingsSubsection.tsx
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
};
|
38
src/components/views/settings/tabs/SettingsTab.tsx
Normal file
38
src/components/views/settings/tabs/SettingsTab.tsx
Normal 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;
|
|
@ -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>
|
||||
{room.getVersion()}
|
||||
</div>
|
||||
{oldRoomLink}
|
||||
{roomUpgradeButton}
|
||||
</SettingsSubsection>
|
||||
</SettingsSection>
|
||||
</SettingsTab>
|
||||
);
|
||||
}
|
||||
}
|
102
src/components/views/settings/tabs/room/BridgeSettingsTab.tsx
Normal file
102
src/components/views/settings/tabs/room/BridgeSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
35
src/components/views/settings/tabs/room/PollHistoryTab.tsx
Normal file
35
src/components/views/settings/tabs/room/PollHistoryTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
476
src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx
Normal file
476
src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
102
src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx
Normal file
102
src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
319
src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
Normal file
319
src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
Normal 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, 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
142
src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx
Normal file
142
src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<AccessibleButton
|
||||
kind="primary_sm"
|
||||
onClick={() => this.viewListRules(list)}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{_t("labs_mjolnir|view_rules")}
|
||||
</AccessibleButton>
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
374
src/components/views/settings/tabs/user/SessionManagerTab.tsx
Normal file
374
src/components/views/settings/tabs/user/SessionManagerTab.tsx
Normal 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;
|
|
@ -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;
|
232
src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
Normal file
232
src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue