feat: add ability to edit custom user attributes

This commit is contained in:
2025-04-10 21:14:10 +02:00
parent 11e996d15a
commit 487a233d1a
9 changed files with 550 additions and 0 deletions
+1
View File
@@ -361,6 +361,7 @@
@import "./views/settings/_ThemeChoicePanel.pcss";
@import "./views/settings/_UpdateCheckButton.pcss";
@import "./views/settings/_UserProfileSettings.pcss";
@import "./views/settings/_ExtendedProfileSettings.pcss";
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@@ -0,0 +1,45 @@
.mx_ExtendedProfileSettings {
margin-top: 24px;
margin-bottom: 16px;
}
.mx_ExtendedProfileSettings h3 {
font-size: 16px;
font-weight: 600;
margin-top: 0;
margin-bottom: 16px;
}
.mx_ExtendedProfileSettings_fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.mx_ExtendedProfileSettings_field {
width: 100%;
margin-bottom: 20px;
}
.mx_ExtendedProfileSettings_fieldsRow {
display: flex;
gap: 16px;
margin-bottom: 20px;
.mx_ExtendedProfileSettings_field {
flex: 1;
margin-bottom: 0;
}
}
.mx_ExtendedProfileSettings_buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.mx_ExtendedProfileSettings_multiline textarea {
min-height: 200px;
resize: vertical;
}
@@ -0,0 +1,302 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ChangeEvent, useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Alert, ErrorMessage } from "@vector-im/compound-web";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { useToastContext } from "../../../contexts/ToastContext";
import InlineSpinner from "../elements/InlineSpinner";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { SdkContextClass } from "../../../contexts/SDKContext";
import Field from "../elements/Field";
import AccessibleButton from "../elements/AccessibleButton";
const SpinnerToast: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<>
<InlineSpinner />
{children}
</>
);
// Extended profile property keys
export const EXT_PROFILE_KEY_TITLE = "io.element.profile.title";
export const EXT_PROFILE_KEY_SPECIALIZATION = "io.element.profile.specialization";
export const EXT_PROFILE_KEY_ADDRESS = "io.element.profile.address";
export const EXT_PROFILE_KEY_WEBSITE = "io.element.profile.website";
export const EXT_PROFILE_KEY_FURTHER_INFO = "io.element.profile.further_info";
// Unstable feature key for extended profiles
export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "org.matrix.msc4133.extended_profiles";
interface ExtendedProfileSettingsProps {
className?: string;
}
/**
* Component for managing extended profile fields for medical professionals.
* Allows editing title, specialization, address, website, and further information.
*/
const ExtendedProfileSettings: React.FC<ExtendedProfileSettingsProps> = ({ className }) => {
const [title, setTitle] = useState<string>("");
const [specialization, setSpecialization] = useState<string>("");
const [address, setAddress] = useState<string>("");
const [website, setWebsite] = useState<string>("");
const [furtherInfo, setFurtherInfo] = useState<string>("");
// Store original values for cancel functionality
const [originalTitle, setOriginalTitle] = useState<string>("");
const [originalSpecialization, setOriginalSpecialization] = useState<string>("");
const [originalAddress, setOriginalAddress] = useState<string>("");
const [originalWebsite, setOriginalWebsite] = useState<string>("");
const [originalFurtherInfo, setOriginalFurtherInfo] = useState<string>("");
const [isModified, setIsModified] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<boolean>(false);
const [loadError, setLoadError] = useState<boolean>(false);
const toastRack = useToastContext();
const client = useMatrixClientContext();
const userProfilesStore = SdkContextClass.instance.userProfilesStore;
const userId = client.getSafeUserId();
// Load extended profile data without checking server support
useEffect(() => {
const loadExtendedProfileData = async () => {
try {
setLoadError(false);
// Try to get existing data
try {
const titleValue = await userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_TITLE);
const specializationValue = await userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_SPECIALIZATION);
const addressValue = await userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_ADDRESS);
const websiteValue = await userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_WEBSITE);
const furtherInfoValue = await userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_FURTHER_INFO);
const titleStr = titleValue as string || "";
const specializationStr = specializationValue as string || "";
const addressStr = addressValue as string || "";
const websiteStr = websiteValue as string || "";
const furtherInfoStr = furtherInfoValue as string || "";
setTitle(titleStr);
setSpecialization(specializationStr);
setAddress(addressStr);
setWebsite(websiteStr);
setFurtherInfo(furtherInfoStr);
// Store original values for cancel functionality
setOriginalTitle(titleStr);
setOriginalSpecialization(specializationStr);
setOriginalAddress(addressStr);
setOriginalWebsite(websiteStr);
setOriginalFurtherInfo(furtherInfoStr);
} catch (e) {
// Just log the error but don't set loadError to true
// as we want the fields to show up even when empty
logger.warn("Failed to fetch extended profile properties", e);
}
} catch (e) {
logger.error("Failed to load extended profile data", e);
setLoadError(true);
}
};
loadExtendedProfileData();
}, [userId, userProfilesStore, client]);
// Check if any field has been modified
useEffect(() => {
const hasChanges =
title !== originalTitle ||
specialization !== originalSpecialization ||
address !== originalAddress ||
website !== originalWebsite ||
furtherInfo !== originalFurtherInfo;
setIsModified(hasChanges);
}, [title, specialization, address, website, furtherInfo,
originalTitle, originalSpecialization, originalAddress, originalWebsite, originalFurtherInfo]);
// Field change handlers
const onTitleChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
}, []);
const onSpecializationChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setSpecialization(e.target.value);
}, []);
const onAddressChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setAddress(e.target.value);
}, []);
const onWebsiteChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setWebsite(e.target.value);
}, []);
const onFurtherInfoChanged = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setFurtherInfo(e.target.value);
}, []);
// Cancel handler - restore all original values
const onCancel = useCallback(() => {
setTitle(originalTitle);
setSpecialization(originalSpecialization);
setAddress(originalAddress);
setWebsite(originalWebsite);
setFurtherInfo(originalFurtherInfo);
}, [originalTitle, originalSpecialization, originalAddress, originalWebsite, originalFurtherInfo]);
// Save handler - save all fields at once
const onSave = useCallback(async (): Promise<void> => {
if (!isModified) return;
setIsSaving(true);
setSaveError(false);
const removeToast = toastRack.displayToast(
<SpinnerToast>{_t("settings|extended_profile_settings|saving_profile")}</SpinnerToast>
);
try {
// Save all modified fields
const savePromises = [];
if (title !== originalTitle) {
savePromises.push(userProfilesStore.setExtendedProfileProperty(EXT_PROFILE_KEY_TITLE, title));
}
if (specialization !== originalSpecialization) {
savePromises.push(userProfilesStore.setExtendedProfileProperty(EXT_PROFILE_KEY_SPECIALIZATION, specialization));
}
if (address !== originalAddress) {
savePromises.push(userProfilesStore.setExtendedProfileProperty(EXT_PROFILE_KEY_ADDRESS, address));
}
if (website !== originalWebsite) {
savePromises.push(userProfilesStore.setExtendedProfileProperty(EXT_PROFILE_KEY_WEBSITE, website));
}
if (furtherInfo !== originalFurtherInfo) {
savePromises.push(userProfilesStore.setExtendedProfileProperty(EXT_PROFILE_KEY_FURTHER_INFO, furtherInfo));
}
await Promise.all(savePromises);
// Update original values after successful save
setOriginalTitle(title);
setOriginalSpecialization(specialization);
setOriginalAddress(address);
setOriginalWebsite(website);
setOriginalFurtherInfo(furtherInfo);
} catch (e) {
logger.error("Failed to save profile information", e);
setSaveError(true);
throw e;
} finally {
setIsSaving(false);
removeToast();
}
}, [
isModified, title, specialization, address, website, furtherInfo,
originalTitle, originalSpecialization, originalAddress, originalWebsite, originalFurtherInfo,
toastRack, userProfilesStore
]);
return (
<div className={classNames("mx_ExtendedProfileSettings", className)}>
<div className="mx_ExtendedProfileSettings_section">
<h3 className="mx_ExtendedProfileSettings_heading">{_t("settings|extended_profile_settings|additional_information")}</h3>
{loadError && (
<Alert title={_t("common|error")} type="critical">
{_t("settings|extended_profile_settings|failed_to_load")}
</Alert>
)}
<div className="mx_ExtendedProfileSettings_fields">
<div className="mx_ExtendedProfileSettings_fieldsRow">
<div className="mx_ExtendedProfileSettings_field mx_ExtendedProfileSettings_field_title">
<Field
label={_t("settings|extended_profile_settings|professional_title")}
value={title}
onChange={onTitleChanged}
disabled={isSaving}
autoComplete="off"
/>
</div>
<div className="mx_ExtendedProfileSettings_field mx_ExtendedProfileSettings_field_specialization">
<Field
label={_t("settings|extended_profile_settings|specialization")}
value={specialization}
onChange={onSpecializationChanged}
disabled={isSaving}
autoComplete="off"
/>
</div>
</div>
<div className="mx_ExtendedProfileSettings_field">
<Field
label={_t("settings|extended_profile_settings|address")}
value={address}
onChange={onAddressChanged}
disabled={isSaving}
autoComplete="off"
/>
</div>
<div className="mx_ExtendedProfileSettings_field">
<Field
label={_t("settings|extended_profile_settings|website")}
value={website}
onChange={onWebsiteChanged}
disabled={isSaving}
autoComplete="off"
/>
</div>
<div className="mx_ExtendedProfileSettings_field">
<Field
className="mx_ExtendedProfileSettings_multiline"
label={_t("settings|extended_profile_settings|further_information")}
value={furtherInfo}
onChange={onFurtherInfoChanged}
disabled={isSaving}
element="textarea"
autoComplete="off"
/>
</div>
{/* Single set of buttons for all fields */}
<div className="mx_ExtendedProfileSettings_buttons">
<AccessibleButton onClick={onCancel} kind="primary_outline" disabled={isSaving || !isModified}>
{_t("common|cancel")}
</AccessibleButton>
<AccessibleButton onClick={onSave} kind="primary" disabled={isSaving || !isModified}>
{_t("common|save")}
</AccessibleButton>
</div>
</div>
{saveError && (
<div className="mx_ExtendedProfileSettings_error">
<ErrorMessage>{_t("settings|extended_profile_settings|failed_to_save")}</ErrorMessage>
</div>
)}
</div>
</div>
);
};
export default ExtendedProfileSettings;
@@ -27,6 +27,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import ExtendedProfileSettings from "./ExtendedProfileSettings";
const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => (
<>
@@ -234,6 +235,10 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
</Alert>
)}
{userIdentifier && <UsernameBox username={userIdentifier} />}
{/* Always display the Extended Profile Settings component */}
<ExtendedProfileSettings className="mx_UserProfileSettings_extendedProfile" />
<Flex gap="var(--cpd-space-4x)" className="mx_UserProfileSettings_profile_buttons">
{externalAccountManagementUrl && (
<ManageAccountButton externalAccountManagementUrl={externalAccountManagementUrl} />
+12
View File
@@ -2558,6 +2558,18 @@
}
},
"settings": {
"extended_profile_settings": {
"additional_information": "Zusätzliche Informationen",
"professional_title": "Titel",
"specialization": "Fachgebiet",
"address": "Adresse",
"website": "Webseite",
"further_information": "Weitere Informationen / Qualifikationen",
"saving_profile": "Profil wird gespeichert...",
"failed_to_load": "Fehler beim Laden der erweiterten Profileinstellungen.",
"failed_to_save": "Fehler beim Speichern der erweiterten Profileinstellungen.",
"server_not_supported": "Dieser Server unterstützt keine erweiterten Profileinstellungen."
},
"account": {
"dialog_title": "<strong>Einstellungen:</strong> Konto",
"title": "Konto"
+12
View File
@@ -2576,6 +2576,18 @@
}
},
"settings": {
"extended_profile_settings": {
"additional_information": "Additional Information",
"professional_title": "Title",
"specialization": "Specialization",
"address": "Address",
"website": "Website",
"further_information": "Further Information / Qualifications",
"saving_profile": "Saving profile...",
"failed_to_load": "Failed to load extended profile settings.",
"failed_to_save": "Failed to save extended profile settings.",
"server_not_supported": "Your server does not support extended profile settings."
},
"account": {
"dialog_title": "<strong>Settings:</strong> Account",
"title": "Account"
+12
View File
@@ -2563,6 +2563,18 @@
}
},
"settings": {
"extended_profile_settings": {
"additional_information": "Informations supplémentaires",
"professional_title": "Titre",
"specialization": "Spécialisation",
"address": "Adresse",
"website": "Site web",
"further_information": "Informations supplémentaires / qualifications",
"saving_profile": "Enregistrement des paramètres de profil étendus…",
"failed_to_load": "Erreur lors du chargement des paramètres de profil étendus.",
"failed_to_save": "Erreur lors de l'enregistrement des paramètres de profil étendus.",
"server_not_supported": "Le serveur d’accueil ne prend pas en charge les paramètres de profil étendus."
},
"account": {
"dialog_title": "<strong>Paramètres : </strong> Compte",
"title": "Compte"
+12
View File
@@ -2175,6 +2175,18 @@
}
},
"settings": {
"extended_profile_settings": {
"additional_information": "Informazioni aggiuntive",
"professional_title": "Titolo",
"specialization": "Specializzazione",
"address": "Indirizzo",
"website": "Sito web",
"further_information": "Ulteriori informazioni / dettagli",
"saving_profile": "Salvataggio del profilo esteso...",
"failed_to_load": "Impossibile caricare le impostazioni del profilo esteso.",
"failed_to_save": "Impossibile salvare le impostazioni del profilo esteso.",
"server_not_supported": "Il tuo server non supporta le impostazioni del profilo esteso."
},
"all_rooms_home": "Mostra tutte le stanze nella pagina principale",
"all_rooms_home_description": "Tutte le stanze in cui sei appariranno nella pagina principale.",
"always_show_message_timestamps": "Mostra sempre l'orario dei messaggi",
+149
View File
@@ -21,6 +21,7 @@ import { LruCache } from "../utils/LruCache";
const cacheSize = 500;
type StoreProfileValue = IMatrixProfile | undefined | null;
type ExtendedProfileValue = string | number | boolean | object | null;
interface GetOptions {
/** Whether calling the function shouuld raise an Error. */
@@ -35,6 +36,8 @@ export class UserProfilesStore {
private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
private extendedProfilesSupport = new Map<string, boolean>();
private extendedProfiles = new Map<string, Map<string, ExtendedProfileValue>>();
public constructor(private client: MatrixClient) {
client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent);
@@ -127,10 +130,156 @@ export class UserProfilesStore {
return profile;
}
/**
* Check if a server supports extended profiles for a user.
* Caches the result.
*
* @param userId - User Id to check
* @returns true if the server supports extended profiles, false otherwise
*/
public async doesServerSupportExtendedProfiles(userId: string): Promise<boolean> {
const serverId = userId.split(":")[1];
if (this.extendedProfilesSupport.has(serverId)) {
return this.extendedProfilesSupport.get(serverId) ?? false;
}
try {
const supported = await this.client.doesServerSupportExtendedProfiles();
this.extendedProfilesSupport.set(serverId, supported);
return supported;
} catch (e) {
logger.warn(`Error checking extended profiles support for serverId ${serverId}`, e);
this.extendedProfilesSupport.set(serverId, false);
return false;
}
}
/**
* Get an extended profile property from cache if available.
*
* @param userId - User Id for which to get the extended profile property
* @param property - The property key to get
* @returns The property value if cached, undefined if not cached
*/
public getExtendedProfileProperty(userId: string, property: string): ExtendedProfileValue | undefined {
const userExtendedProfile = this.extendedProfiles.get(userId);
if (!userExtendedProfile) {
return undefined;
}
return userExtendedProfile.get(property);
}
/**
* Get or fetch an extended profile property.
*
* @param userId - User Id for which to get the extended profile property
* @param property - The property key to get
* @returns The property value, null if it doesn't exist
*/
public async getOrFetchExtendedProfileProperty(userId: string, property: string): Promise<ExtendedProfileValue | null> {
const cached = this.getExtendedProfileProperty(userId, property);
if (cached !== undefined) {
return cached;
}
return await this.fetchExtendedProfileProperty(userId, property);
}
/**
* Fetch an extended profile property from the API.
* Caches the result.
*
* @param userId - User Id for which to fetch the extended profile property
* @param property - The property key to fetch
* @returns The property value, null if it doesn't exist or there was an error
*/
public async fetchExtendedProfileProperty(userId: string, property: string): Promise<ExtendedProfileValue | null> {
// Removed server support check - assume support is always available
try {
const rawValue = await this.client.getExtendedProfileProperty(userId, property);
// Ensure the value is of type ExtendedProfileValue
const value: ExtendedProfileValue = this.validateExtendedProfileValue(rawValue);
// Cache the value
if (!this.extendedProfiles.has(userId)) {
this.extendedProfiles.set(userId, new Map());
}
this.extendedProfiles.get(userId)?.set(property, value);
return value;
} catch (e) {
logger.warn(`Error retrieving extended profile property ${property} for userId ${userId}`, e);
return null;
}
}
// Hilfsmethode, um den Rückgabewert zu validieren
private validateExtendedProfileValue(value: unknown): ExtendedProfileValue {
if (value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
(typeof value === "object" && value !== null)) {
return value as ExtendedProfileValue;
}
// Fallback für ungültige Werte
logger.warn(`Invalid extended profile value type: ${typeof value}`);
return null;
}
/**
* Set an extended profile property for the current user.
*
* @param property - The property key to set
* @param value - The value to set
* @returns true if successful, false otherwise
*/
public async setExtendedProfileProperty(property: string, value: ExtendedProfileValue): Promise<boolean> {
try {
await this.client.setExtendedProfileProperty(property, value);
const userId = this.client.getSafeUserId();
if (!this.extendedProfiles.has(userId)) {
this.extendedProfiles.set(userId, new Map());
}
this.extendedProfiles.get(userId)?.set(property, value);
return true;
} catch (e) {
logger.warn(`Error setting extended profile property ${property}`, e);
return false;
}
}
/**
* Delete an extended profile property for the current user.
*
* @param property - The property key to delete
* @returns true if successful, false otherwise
*/
public async deleteExtendedProfileProperty(property: string): Promise<boolean> {
try {
await this.client.deleteExtendedProfileProperty(property);
const userId = this.client.getSafeUserId();
if (this.extendedProfiles.has(userId)) {
this.extendedProfiles.get(userId)?.delete(property);
}
return true;
} catch (e) {
logger.warn(`Error deleting extended profile property ${property}`, e);
return false;
}
}
public flush(): void {
this.profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
this.profileLookupErrors = new LruCache<string, MatrixError>(cacheSize);
this.knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
this.extendedProfilesSupport = new Map<string, boolean>();
this.extendedProfiles = new Map<string, Map<string, ExtendedProfileValue>>();
}
/**