feat: add view to see custom user profile fields

This commit is contained in:
Joël Poyet
2025-04-10 23:44:21 +02:00
committed by Joël
parent f0490456a9
commit 047a001925
9 changed files with 541 additions and 0 deletions
@@ -0,0 +1,79 @@
/*
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.
*/
.mx_ExtendedProfileView {
/* Anpassung an das bestehende UserInfo Panel */
font-size: var(--cpd-font-size-body-sm);
padding: 0;
/* Styling für das Accordion */
.mx_ExtendedProfileView_accordion {
width: 100%;
}
/* Abstand zwischen den Feldern */
.mx_ExtendedProfileView_content {
padding: var(--cpd-space-2x) 0;
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
}
/* Styling für die Felder */
.mx_ExtendedProfileView_field {
margin-bottom: var(--cpd-space-2x);
.mx_ExtendedProfileView_icon {
width: 20px;
height: 20px;
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
}
}
/* Labels und Werte */
.mx_ExtendedProfileView_label {
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-xs);
margin-bottom: var(--cpd-space-1x);
}
.mx_ExtendedProfileView_value {
color: var(--cpd-color-text-primary);
word-break: break-word;
&.mx_ExtendedProfileView_multiline {
white-space: pre-line;
margin-top: var(--cpd-space-1x);
}
}
/* Spezielle Styles für Website-Links */
.mx_ExtendedProfileView_link {
color: var(--cpd-color-text-action-accent);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Für Bereich "Weitere Informationen" */
.mx_ExtendedProfileView_further_info {
padding: var(--cpd-space-1x) 0;
}
/* Loading State */
.mx_ExtendedProfileView_loading {
padding: var(--cpd-space-4x) 0;
}
/* Error State */
.mx_ExtendedProfileView_error {
margin: var(--cpd-space-2x) 0;
}
}
@@ -0,0 +1,59 @@
/*
Copyright 2025 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
*/
.mx_UserExtendedProfileInfo {
padding: 16px;
background-color: $system;
}
.mx_UserExtendedProfileInfo_loading {
display: flex;
justify-content: center;
padding: 20px 0;
}
.mx_UserExtendedProfileInfo_heading {
margin-bottom: 12px;
}
.mx_UserExtendedProfileInfo_content {
display: flex;
flex-direction: column;
gap: 12px;
}
.mx_UserExtendedProfileInfo_row {
display: flex;
gap: 16px;
}
.mx_UserExtendedProfileInfo_row .mx_UserExtendedProfileInfo_field {
flex: 1;
}
.mx_UserExtendedProfileInfo_field {
margin-bottom: 4px;
}
.mx_UserExtendedProfileInfo_label {
margin-bottom: 3px;
color: $tertiary-content;
}
.mx_UserExtendedProfileInfo_value {
word-break: break-word;
overflow-wrap: break-word;
}
/* Dark theme specific styles */
.mx_UserExtendedProfileInfo_value a {
color: $accent;
text-decoration: none;
}
.mx_UserExtendedProfileInfo_value a:hover {
text-decoration: underline;
}
@@ -0,0 +1,196 @@
/*
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, { useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Alert } from "@vector-im/compound-web";
import GlobeIcon from "@vector-im/compound-design-tokens/assets/web/icons/globe";
import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location";
import BadgeIcon from "@vector-im/compound-design-tokens/assets/web/icons/badge";
import StarIcon from "@vector-im/compound-design-tokens/assets/web/icons/star";
import { _t } from "../../../languageHandler";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { Container } from "../right_panel/UserInfo";
import InlineSpinner from "../elements/InlineSpinner";
import {
EXT_PROFILE_KEY_ADDRESS,
EXT_PROFILE_KEY_FURTHER_INFO,
EXT_PROFILE_KEY_SPECIALIZATION,
EXT_PROFILE_KEY_TITLE,
EXT_PROFILE_KEY_WEBSITE,
} from "../settings/ExtendedProfileSettings";
import { Flex } from "../../utils/Flex";
interface ExtendedProfileViewProps {
userId: string;
}
type ExtendedProfileData = {
title: string | null;
specialization: string | null;
address: string | null;
website: string | null;
furtherInfo: string | null;
};
/**
* Komponente zur Anzeige erweiterter Profilfelder eines anderen Benutzers
*/
const ExtendedProfileView: React.FC<ExtendedProfileViewProps> = ({ userId }) => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);
const [profileData, setProfileData] = useState<ExtendedProfileData | null>(null);
const [hasData, setHasData] = useState<boolean>(false);
//const client = useMatrixClientContext();
const userProfilesStore = SdkContextClass.instance.userProfilesStore;
// Laden der erweiterten Profilfelder für den angegebenen Benutzer
const fetchExtendedProfileData = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(false);
try {
const [
titleValue,
specializationValue,
addressValue,
websiteValue,
furtherInfoValue,
] = await Promise.all([
userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_TITLE),
userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_SPECIALIZATION),
userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_ADDRESS),
userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_WEBSITE),
userProfilesStore.getOrFetchExtendedProfileProperty(userId, EXT_PROFILE_KEY_FURTHER_INFO),
]);
const data = {
title: titleValue as string | null,
specialization: specializationValue as string | null,
address: addressValue as string | null,
website: websiteValue as string | null,
furtherInfo: furtherInfoValue as string | null,
};
setProfileData(data);
// Prüfen, ob mindestens ein Feld ausgefüllt ist
const hasAnyData = Object.values(data).some((value) => value !== null && value !== "");
setHasData(hasAnyData);
} catch (e) {
logger.error("Fehler beim Laden der erweiterten Profildaten", e);
setError(true);
} finally {
setLoading(false);
}
}, [userId, userProfilesStore]);
useEffect(() => {
fetchExtendedProfileData();
}, [fetchExtendedProfileData]);
// Wenn keine Daten vorhanden sind, zeigen wir nichts an
if (!loading && !hasData && !error) {
return null;
}
return (
<Container className="mx_ExtendedProfileView">
{loading ? (
<Flex justify="center" align="center" className="mx_ExtendedProfileView_loading">
<InlineSpinner />
</Flex>
) : error ? (
<Alert
type="critical"
title={_t("common|error")}
className="mx_ExtendedProfileView_error"
>
{_t("Fehler beim Laden der Profilinformationen")}
</Alert>
) : hasData ? (
<div className="mx_ExtendedProfileView_content">
{(profileData?.title || profileData?.specialization) && (
<div className="mx_ExtendedProfileView_section mx_ExtendedProfileView_professional">
{profileData.title && (
<div className="mx_ExtendedProfileView_field">
<Flex align="center" gap="var(--cpd-space-2x)">
<BadgeIcon className="mx_ExtendedProfileView_icon" />
<div>
<div className="mx_ExtendedProfileView_label">{_t("settings|extended_profile_settings|professional_title")}</div>
<div className="mx_ExtendedProfileView_value">{profileData.title}</div>
</div>
</Flex>
</div>
)}
{profileData.specialization && (
<div className="mx_ExtendedProfileView_field">
<Flex align="center" gap="var(--cpd-space-2x)">
<StarIcon className="mx_ExtendedProfileView_icon" />
<div>
<div className="mx_ExtendedProfileView_label">{_t("settings|extended_profile_settings|specialization")}</div>
<div className="mx_ExtendedProfileView_value">{profileData.specialization}</div>
</div>
</Flex>
</div>
)}
</div>
)}
{profileData?.address && (
<div className="mx_ExtendedProfileView_field">
<Flex align="center" gap="var(--cpd-space-2x)">
<LocationIcon className="mx_ExtendedProfileView_icon" />
<div>
<div className="mx_ExtendedProfileView_label">{_t("settings|extended_profile_settings|address")}</div>
<div className="mx_ExtendedProfileView_value">{profileData.address}</div>
</div>
</Flex>
</div>
)}
{profileData?.website && (
<div className="mx_ExtendedProfileView_field">
<Flex align="center" gap="var(--cpd-space-2x)">
<GlobeIcon className="mx_ExtendedProfileView_icon" />
<div>
<div className="mx_ExtendedProfileView_label">{_t("settings|extended_profile_settings|website")}</div>
<div className="mx_ExtendedProfileView_value">
<a
href={profileData.website.startsWith("http") ? profileData.website : `https://${profileData.website}`}
target="_blank"
rel="noreferrer noopener"
className="mx_ExtendedProfileView_link"
>
{profileData.website}
</a>
</div>
</div>
</Flex>
</div>
)}
{profileData?.furtherInfo && (
<div className="mx_ExtendedProfileView_field">
<div className="mx_ExtendedProfileView_further_info">
<div className="mx_ExtendedProfileView_label">{_t("settings|extended_profile_settings|further_information")}</div>
<div className="mx_ExtendedProfileView_value mx_ExtendedProfileView_multiline">{profileData.furtherInfo}</div>
</div>
</div>
)}
</div>
) : null}
</Container>
);
};
export default ExtendedProfileView;
@@ -0,0 +1,165 @@
/*
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, { useEffect, useState } from "react";
import { Text } from "@vector-im/compound-web";
import { SdkContextClass } from "../../../contexts/SDKContext";
import Spinner from "../elements/Spinner";
import { _t } from "../../../languageHandler";
// Extended profile property keys
import {
EXT_PROFILE_KEY_TITLE,
EXT_PROFILE_KEY_SPECIALIZATION,
EXT_PROFILE_KEY_ADDRESS,
EXT_PROFILE_KEY_WEBSITE,
EXT_PROFILE_KEY_FURTHER_INFO,
} from "../settings/ExtendedProfileSettings";
// Helper function to preserve line breaks
const renderMultilineText = (text: string): React.ReactNode[] => {
const lines = text.split("\n");
return lines.map((line, i) => (
<React.Fragment key={i}>
{line}
{i < lines.length - 1 && <br />}
</React.Fragment>
));
};
interface UserExtendedProfileInfoProps {
userId: string;
}
const UserExtendedProfileInfo: React.FC<UserExtendedProfileInfoProps> = ({ userId }) => {
//const client = useMatrixClientContext();
const userProfilesStore = SdkContextClass.instance.userProfilesStore;
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>("");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
const fetchProfileData = async () => {
try {
setError(false);
setLoading(true);
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);
setTitle(titleValue as string || "");
setSpecialization(specializationValue as string || "");
setAddress(addressValue as string || "");
setWebsite(websiteValue as string || "");
setFurtherInfo(furtherInfoValue as string || "");
} catch {
setError(true);
} finally {
setLoading(false);
}
};
fetchProfileData();
}, [userId, userProfilesStore]);
if (loading) {
return <Spinner />;
}
if (error) {
return <div className="mx_UserExtendedProfileInfo_error">{_t("user_info|extended_profile|error_loading_profile")}</div>;
}
// Don't render anything if there's no data
if (!title && !specialization && !address && !website && !furtherInfo) {
return null;
}
return (
<div className="mx_UserInfo_container mx_UserInfo_profileInfo">
{/* <div className="mx_UserInfo_container_header">
<Text weight="semibold">{_t("user_info|extended_profile|header")}</Text>
</div> */}
{title && (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_fieldLabel">
<Text size="sm" weight="semibold">
{_t("user_info|extended_profile|title")}
</Text>
</div>
<div className="mx_UserInfo_fieldValue">
<Text size="sm">{title}</Text>
</div>
</div>
)}
{specialization && (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_fieldLabel">
<Text size="sm" weight="semibold">
{_t("user_info|extended_profile|specialization")}
</Text>
</div>
<div className="mx_UserInfo_fieldValue">
<Text size="sm">{specialization}</Text>
</div>
</div>
)}
{address && (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_fieldLabel">
<Text size="sm" weight="semibold">
{_t("user_info|extended_profile|address")}
</Text>
</div>
<div className="mx_UserInfo_fieldValue">
<Text size="sm">{renderMultilineText(address)}</Text>
</div>
</div>
)}
{website && (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_fieldLabel">
<Text size="sm" weight="semibold">
{_t("user_info|extended_profile|website")}
</Text>
</div>
<div className="mx_UserInfo_fieldValue">
<Text size="sm">
<a href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
rel="noreferrer noopener">
{website}
</a>
</Text>
</div>
</div>
)}
{furtherInfo && (
<div className="mx_UserInfo_profileField">
<div className="mx_UserInfo_fieldLabel">
<Text size="sm" weight="semibold">
{_t("user_info|extended_profile|further_info")}
</Text>
</div>
<div className="mx_UserInfo_fieldValue mx_UserInfo_fieldValue_multiline">
<Text size="sm">{renderMultilineText(furtherInfo)}</Text>
</div>
</div>
)}
</div>
);
};
export default UserExtendedProfileInfo;
@@ -28,6 +28,7 @@ import { type IRightPanelCardState } from "../../../stores/right-panel/RightPane
import PosthogTrackers from "../../../PosthogTrackers";
import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
import { UserInfoBasicView } from "./user_info/UserInfoBasicView";
import UserExtendedProfileInfo from "./UserExtendedProfileInfo";
export interface IDevice extends Device {
ambiguous?: boolean;
@@ -252,6 +253,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
devices={devices}
roomId={room?.roomId}
/>
{phase === RightPanelPhases.MemberInfo && <UserExtendedProfileInfo userId={member.userId} />}
</>
);
+10
View File
@@ -3751,6 +3751,16 @@
}
},
"user_info": {
"extended_profile": {
"header": "Weitere Informationen",
"title": "Titel",
"specialization": "Fachgebiet",
"address": "Adresse",
"website": "Webseite",
"further_info": "Weitere Infos / Qualifikationen",
"not_provided": "Nicht angegeben",
"error_loading_profile": "Fehler beim Laden des Profils"
},
"admin_tools_section": "Administrationswerkzeuge",
"ban_button_room": "Bannen",
"ban_button_space": "Bannen",
+10
View File
@@ -3807,6 +3807,16 @@
}
},
"user_info": {
"extended_profile": {
"header": "Additional infos",
"title": "Title",
"specialization": "Speciality",
"address": "Address",
"website": "Website",
"further_info": "Further infos / qualifications",
"not_provided": "Not provided",
"error_loading_profile": "Error loading profile"
},
"admin_tools_section": "Admin Tools",
"ban_button_room": "Ban from room",
"ban_button_space": "Ban from space",
+10
View File
@@ -3811,6 +3811,16 @@
}
},
"user_info": {
"extended_profile": {
"header": "Informations supplémentaires",
"title": "Titre",
"specialization": "Spécialisation",
"address": "Adresse",
"website": "Site web",
"further_info": "Informations supplémentaires / qualifications",
"not_provided": "Non fourni",
"error_loading_profile": "Erreur lors du chargement du profil"
},
"admin_tools_section": "Outils d’administration",
"ban_button_room": "Bannir du salon",
"ban_button_space": "Bannir de l'espace",
+10
View File
@@ -2194,6 +2194,16 @@
}
},
"user_info": {
"extended_profile": {
"header": "Informazioni aggiuntive",
"title": "Titolo",
"specialization": "Specializzazione",
"address": "Indirizzo",
"website": "Sito web",
"further_info": "Ulteriori informazioni / qualifiche",
"not_provided": "Non fornito",
"error_loading_profile": "Errore nel caricamento del profilo"
},
"admin_tools_section": "Administratoriaus įrankiai",
"ban_button_room": "Užblokuoti iš kambario",
"ban_button_space": "Užblokuoti iš erdvės",