From 0f6300845d2c14aa907c723b2f7286f9dc239ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Poyet?= Date: Thu, 10 Apr 2025 21:14:10 +0200 Subject: [PATCH] feat: add ability to edit custom user attributes --- apps/web/res/css/_components.pcss | 1 + .../settings/_ExtendedProfileSettings.pcss | 45 +++ .../settings/ExtendedProfileSettings.tsx | 302 ++++++++++++++++++ .../views/settings/UserProfileSettings.tsx | 5 + apps/web/src/i18n/strings/de_DE.json | 12 + apps/web/src/i18n/strings/en_EN.json | 12 + apps/web/src/i18n/strings/fr.json | 12 + apps/web/src/i18n/strings/it.json | 12 + apps/web/src/stores/UserProfilesStore.ts | 149 +++++++++ 9 files changed, 550 insertions(+) create mode 100644 apps/web/res/css/views/settings/_ExtendedProfileSettings.pcss create mode 100644 apps/web/src/components/views/settings/ExtendedProfileSettings.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 0c2757148f..d083b7a04e 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -323,6 +323,7 @@ @import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/_UserPersonalInfoSettings.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"; diff --git a/apps/web/res/css/views/settings/_ExtendedProfileSettings.pcss b/apps/web/res/css/views/settings/_ExtendedProfileSettings.pcss new file mode 100644 index 0000000000..8786103941 --- /dev/null +++ b/apps/web/res/css/views/settings/_ExtendedProfileSettings.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; +} \ No newline at end of file diff --git a/apps/web/src/components/views/settings/ExtendedProfileSettings.tsx b/apps/web/src/components/views/settings/ExtendedProfileSettings.tsx new file mode 100644 index 0000000000..87ae9235ff --- /dev/null +++ b/apps/web/src/components/views/settings/ExtendedProfileSettings.tsx @@ -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 }) => ( + <> + + {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 = ({ className }) => { + const [title, setTitle] = useState(""); + const [specialization, setSpecialization] = useState(""); + const [address, setAddress] = useState(""); + const [website, setWebsite] = useState(""); + const [furtherInfo, setFurtherInfo] = useState(""); + + // Store original values for cancel functionality + const [originalTitle, setOriginalTitle] = useState(""); + const [originalSpecialization, setOriginalSpecialization] = useState(""); + const [originalAddress, setOriginalAddress] = useState(""); + const [originalWebsite, setOriginalWebsite] = useState(""); + const [originalFurtherInfo, setOriginalFurtherInfo] = useState(""); + + const [isModified, setIsModified] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(false); + const [loadError, setLoadError] = useState(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) => { + setTitle(e.target.value); + }, []); + + const onSpecializationChanged = useCallback((e: ChangeEvent) => { + setSpecialization(e.target.value); + }, []); + + const onAddressChanged = useCallback((e: ChangeEvent) => { + setAddress(e.target.value); + }, []); + + const onWebsiteChanged = useCallback((e: ChangeEvent) => { + setWebsite(e.target.value); + }, []); + + const onFurtherInfoChanged = useCallback((e: ChangeEvent) => { + 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 => { + if (!isModified) return; + + setIsSaving(true); + setSaveError(false); + const removeToast = toastRack.displayToast( + {_t("settings|extended_profile_settings|saving_profile")} + ); + + 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 ( +
+
+

{_t("settings|extended_profile_settings|additional_information")}

+ + {loadError && ( + + {_t("settings|extended_profile_settings|failed_to_load")} + + )} + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + {/* Single set of buttons for all fields */} +
+ + {_t("common|cancel")} + + + {_t("common|save")} + +
+
+ + {saveError && ( +
+ {_t("settings|extended_profile_settings|failed_to_save")} +
+ )} +
+
+ ); +}; + +export default ExtendedProfileSettings; diff --git a/apps/web/src/components/views/settings/UserProfileSettings.tsx b/apps/web/src/components/views/settings/UserProfileSettings.tsx index 0ecd5d3dfa..d18dce332c 100644 --- a/apps/web/src/components/views/settings/UserProfileSettings.tsx +++ b/apps/web/src/components/views/settings/UserProfileSettings.tsx @@ -28,6 +28,7 @@ import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog"; import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { SettingsSection } from "./shared/SettingsSection.tsx"; +import ExtendedProfileSettings from "./ExtendedProfileSettings"; const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => ( <> @@ -237,6 +238,10 @@ const UserProfileSettings: React.FC = ({ )} {userIdentifier && } + + {/* Always display the Extended Profile Settings component */} + + {externalAccountManagementUrl && ( diff --git a/apps/web/src/i18n/strings/de_DE.json b/apps/web/src/i18n/strings/de_DE.json index 9e9fe76a8a..a8c31f073d 100644 --- a/apps/web/src/i18n/strings/de_DE.json +++ b/apps/web/src/i18n/strings/de_DE.json @@ -2441,6 +2441,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": "Einstellungen: Konto", "title": "Konto" diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 2cb5bdab63..8e116bb600 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -2484,6 +2484,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": "Settings: Account", "title": "Account" diff --git a/apps/web/src/i18n/strings/fr.json b/apps/web/src/i18n/strings/fr.json index 47333cb3c4..c0aa6ded84 100644 --- a/apps/web/src/i18n/strings/fr.json +++ b/apps/web/src/i18n/strings/fr.json @@ -2488,6 +2488,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": "Paramètres : Compte", "title": "Compte" diff --git a/apps/web/src/i18n/strings/it.json b/apps/web/src/i18n/strings/it.json index 674e1972ab..31953260e2 100644 --- a/apps/web/src/i18n/strings/it.json +++ b/apps/web/src/i18n/strings/it.json @@ -2119,6 +2119,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", diff --git a/apps/web/src/stores/UserProfilesStore.ts b/apps/web/src/stores/UserProfilesStore.ts index 049c343736..e938aeefc9 100644 --- a/apps/web/src/stores/UserProfilesStore.ts +++ b/apps/web/src/stores/UserProfilesStore.ts @@ -20,6 +20,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. */ @@ -33,6 +34,8 @@ interface GetOptions { export class UserProfilesStore { private profiles = new LruCache(cacheSize); private knownProfiles = new LruCache(cacheSize); + private extendedProfilesSupport = new Map(); + private extendedProfiles = new Map>(); public constructor(private client: MatrixClient) { client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent); @@ -115,9 +118,155 @@ 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 { + 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 { + 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 { + // 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 { + 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 { + 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(cacheSize); this.knownProfiles = new LruCache(cacheSize); + this.extendedProfilesSupport = new Map(); + this.extendedProfiles = new Map>(); } /**