From fff6788e94197fc3f2f4a837415350a95b5b98fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Mon, 13 Jan 2025 20:38:14 +0100 Subject: [PATCH] View custom profile attributes (#8460) * feat: Add custom attributes section to user profile --- app/client/rest/base.ts | 4 + .../rest/custom_profile_attributes.test.ts | 35 ++++++++ app/client/rest/custom_profile_attributes.ts | 27 ++++++ app/client/rest/index.ts | 5 +- app/client/rest/users.ts | 7 ++ .../user_profile/custom_attributes.tsx | 73 ++++++++++++++++ app/screens/user_profile/index.ts | 1 + app/screens/user_profile/label.tsx | 15 +++- app/screens/user_profile/user_info.tsx | 83 +++++++++++++------ app/screens/user_profile/user_profile.tsx | 9 +- types/api/config.d.ts | 1 + types/api/custom_profile_attributes.d.ts | 49 +++++++++++ types/api/users.d.ts | 36 ++++++++ 13 files changed, 311 insertions(+), 34 deletions(-) create mode 100644 app/client/rest/custom_profile_attributes.test.ts create mode 100644 app/client/rest/custom_profile_attributes.ts create mode 100644 app/screens/user_profile/custom_attributes.tsx create mode 100644 types/api/custom_profile_attributes.d.ts diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index d66c2c7f0..e50e883ef 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -199,6 +199,10 @@ export default class ClientBase extends ClientTracking { return `${this.urlVersion}/client_perf`; } + getCustomProfileAttributesRoute() { + return `${this.urlVersion}/custom_profile_attributes`; + } + doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => { return this.doFetchWithTracking(url, options, returnDataOnly); }; diff --git a/app/client/rest/custom_profile_attributes.test.ts b/app/client/rest/custom_profile_attributes.test.ts new file mode 100644 index 000000000..5227ec558 --- /dev/null +++ b/app/client/rest/custom_profile_attributes.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import TestHelper from '@test/test_helper'; + +import type ClientBase from './base'; +import type {ClientCustomAttributesMix} from './custom_profile_attributes'; + +describe('CustomAttributes', () => { + let client: ClientCustomAttributesMix & ClientBase; + + beforeAll(() => { + client = TestHelper.createClient(); + client.doFetch = jest.fn(); + }); + + test('getCustomProfileAttributeFields', async () => { + await client.getCustomProfileAttributeFields(); + + expect(client.doFetch).toHaveBeenCalledWith( + `${client.getCustomProfileAttributesRoute()}/fields`, + {method: 'get'}, + ); + }); + + test('getCustomProfileAttributeValues', async () => { + const userId = 'user1'; + await client.getCustomProfileAttributeValues(userId); + + expect(client.doFetch).toHaveBeenCalledWith( + `${client.getUserRoute(userId)}/custom_profile_attributes`, + {method: 'get'}, + ); + }); +}); diff --git a/app/client/rest/custom_profile_attributes.ts b/app/client/rest/custom_profile_attributes.ts new file mode 100644 index 000000000..254bceb29 --- /dev/null +++ b/app/client/rest/custom_profile_attributes.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type ClientBase from './base'; + +export interface ClientCustomAttributesMix { + getCustomProfileAttributeFields: () => Promise; + getCustomProfileAttributeValues: (userID: string) => Promise; +} + +const ClientCustomAttributes = >(superclass: TBase) => class extends superclass { + getCustomProfileAttributeFields = async () => { + return this.doFetch( + `${this.getCustomProfileAttributesRoute()}/fields`, + {method: 'get'}, + ); + }; + + getCustomProfileAttributeValues = async (userID: string) => { + return this.doFetch( + `${this.getUserRoute(userID)}/custom_profile_attributes`, + {method: 'get'}, + ); + }; +}; + +export default ClientCustomAttributes; diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index e023d8ca3..a0f86d1b5 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -11,6 +11,7 @@ import ClientCategories, {type ClientCategoriesMix} from './categories'; import ClientChannelBookmarks, {type ClientChannelBookmarksMix} from './channel_bookmark'; import ClientChannels, {type ClientChannelsMix} from './channels'; import {DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID} from './constants'; +import ClientCustomAttributes, {type ClientCustomAttributesMix} from './custom_profile_attributes'; import ClientEmojis, {type ClientEmojisMix} from './emojis'; import ClientFiles, {type ClientFilesMix} from './files'; import ClientGeneral, {type ClientGeneralMix} from './general'; @@ -44,7 +45,8 @@ interface Client extends ClientBase, ClientUsersMix, ClientCallsMix, ClientPluginsMix, - ClientNPSMix + ClientNPSMix, + ClientCustomAttributesMix {} class Client extends mix(ClientBase).with( @@ -66,6 +68,7 @@ class Client extends mix(ClientBase).with( ClientCalls, ClientPlugins, ClientNPS, + ClientCustomAttributes, ) { // eslint-disable-next-line no-useless-constructor constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) { diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index 5b0fcac9b..840f045a5 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -258,6 +258,13 @@ const ClientUsers = >(superclass: TBase) = ); }; + getCustomProfileAttributeFields = async () => { + return this.doFetch( + `${this.getCustomProfileAttributesRoute()}/fields`, + {method: 'get'}, + ); + }; + getUserByUsername = async (username: string) => { return this.doFetch( `${this.getUsersRoute()}/username/${username}`, diff --git a/app/screens/user_profile/custom_attributes.tsx b/app/screens/user_profile/custom_attributes.tsx new file mode 100644 index 000000000..199f80a65 --- /dev/null +++ b/app/screens/user_profile/custom_attributes.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {View, StyleSheet, type ListRenderItem} from 'react-native'; +import {FlatList} from 'react-native-gesture-handler'; + +import UserProfileLabel from './label'; + +type Props = { + nickname?: string; + position?: string; + localTime?: string; + customAttributes?: DisplayCustomAttribute[]; +} + +const renderAttribute: ListRenderItem = ({item}) => ( + +); + +const CustomAttributes = ({nickname, position, localTime, customAttributes}: Props) => { + const {formatMessage} = useIntl(); + + // Combine standard and custom attributes + const mergeAttributes = [ + (nickname ? { + id: 'nickname', + name: formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'}), + value: nickname, + } : {}), + (position ? { + id: 'position', + name: formatMessage({id: 'channel_info.position', defaultMessage: 'Position'}), + value: position, + } : {}), + (localTime ? { + id: 'local_time', + name: formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'}), + value: localTime, + } : {}), + + ...(customAttributes ?? []), + ]; + + // remove any empty objects + const attributes = mergeAttributes.filter((v) => Object.entries(v).length !== 0); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginTop: 12, + flex: 1, + }, +}); + +export default CustomAttributes; diff --git a/app/screens/user_profile/index.ts b/app/screens/user_profile/index.ts index f946ee9fc..e070d7d8f 100644 --- a/app/screens/user_profile/index.ts +++ b/app/screens/user_profile/index.ts @@ -70,6 +70,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro user, canChangeMemberRoles, hideGuestTags: observeConfigBooleanValue(database, 'HideGuestTags'), + enableCustomAttributes: observeConfigBooleanValue(database, 'FeatureFlagCustomProfileAttributes'), }; }); diff --git a/app/screens/user_profile/label.tsx b/app/screens/user_profile/label.tsx index ea3198c0a..e24095772 100644 --- a/app/screens/user_profile/label.tsx +++ b/app/screens/user_profile/label.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {Text, View} from 'react-native'; import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; type Props = { @@ -17,15 +17,21 @@ type Props = { const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { marginVertical: 8, + flexDirection: 'row', + alignItems: 'center', }, description: { color: theme.centerChannelColor, + flex: 2, ...typography('Body', 200), }, title: { - color: changeOpacity(theme.centerChannelColor, 0.56), - marginBottom: 2, - ...typography('Body', 50, 'SemiBold'), + color: theme.centerChannelColor, + flex: 1, + marginRight: 20, + height: '100%', + alignItems: 'flex-start', + ...typography('Body', 100, 'SemiBold'), }, })); @@ -38,6 +44,7 @@ const UserProfileLabel = ({title, description, testID}: Props) => { {title} diff --git a/app/screens/user_profile/user_info.tsx b/app/screens/user_profile/user_info.tsx index 7905d7f7d..97c4b7a2e 100644 --- a/app/screens/user_profile/user_info.tsx +++ b/app/screens/user_profile/user_info.tsx @@ -1,12 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {useIntl} from 'react-intl'; +import React, {useEffect, useRef, useState} from 'react'; + +import {useServerUrl} from '@context/server'; +import NetworkManager from '@managers/network_manager'; import {getUserCustomStatus} from '@utils/user'; +import CustomAttributes from './custom_attributes'; import UserProfileCustomStatus from './custom_status'; -import UserProfileLabel from './label'; import type {UserModel} from '@database/models/server'; @@ -17,36 +19,63 @@ type Props = { showNickname: boolean; showPosition: boolean; user: UserModel; + enableCustomAttributes?: boolean; } -const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user}: Props) => { - const {formatMessage} = useIntl(); +const emptyList: DisplayCustomAttribute[] = []; /** avoid re-renders **/ + +const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user, enableCustomAttributes}: Props) => { const customStatus = getUserCustomStatus(user); + const serverUrl = useServerUrl(); + const [customAttributes, setCustomAttributes] = useState(emptyList); + const lastRequest = useRef(0); + + useEffect(() => { + if (enableCustomAttributes) { + const fetchData = async () => { + const reqTime = Date.now(); + lastRequest.current = reqTime; + try { + const client = NetworkManager.getClient(serverUrl); + const [fields, attrValues] = await Promise.all([ + client.getCustomProfileAttributeFields(), + client.getCustomProfileAttributeValues(user.id), + ]); + + // ignore results if there was a newer request + if (fields && fields.length > 0 && lastRequest.current === reqTime) { + const attributes = fields.map((field) => { + if (attrValues[field.id]) { + return ({ + id: field.id, + name: field.name, + value: attrValues[field.id] || '', + } as DisplayCustomAttribute); + } + return {} as DisplayCustomAttribute; // this will be cleaned out in CustomAttributes along with the fixed attributes. + }); + setCustomAttributes(attributes); + } + } catch { + setCustomAttributes(emptyList); + } + }; + + fetchData(); + } else { + setCustomAttributes(emptyList); + } + }, [enableCustomAttributes, serverUrl, user.id]); return ( <> - {showCustomStatus && } - {showNickname && - - } - {showPosition && - - } - {showLocalTime && - - } + {showCustomStatus && } + ); }; diff --git a/app/screens/user_profile/user_profile.tsx b/app/screens/user_profile/user_profile.tsx index a8ca2d1ae..1537a9b53 100644 --- a/app/screens/user_profile/user_profile.tsx +++ b/app/screens/user_profile/user_profile.tsx @@ -46,6 +46,7 @@ type Props = { userIconOverride?: string; usernameOverride?: string; hideGuestTags: boolean; + enableCustomAttributes: boolean; } const TITLE_HEIGHT = 118; @@ -86,6 +87,7 @@ const UserProfile = ({ userIconOverride, usernameOverride, hideGuestTags, + enableCustomAttributes, }: Props) => { const {formatMessage, locale} = useIntl(); const serverUrl = useServerUrl(); @@ -150,12 +152,14 @@ const UserProfile = ({ return [ 1, bottomSheetSnapPoint(optionsCount, LABEL_HEIGHT) + title + extraHeight, + '90%', ]; }, [ headerText, showUserProfileOptions, showCustomStatus, showNickname, showPosition, showLocalTime, manageMode, bottom, showOptions, canChangeMemberRoles, canManageAndRemoveMembers, + enableCustomAttributes, ]); useEffect(() => { @@ -189,7 +193,7 @@ const UserProfile = ({ userId={user.id} /> } - {!manageMode && + {!manageMode && ( - } + )} {manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) &&