// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import moment from 'moment-timezone'; import {defineMessages, type IntlShape} from 'react-intl'; import {Alert} from 'react-native'; import {General, Permissions, Preferences} from '@constants'; import {Ringtone} from '@constants/calls'; import {CustomStatusDurationEnum} from '@constants/custom_status'; import {DEFAULT_LOCALE, getLocalizedMessage} from '@i18n'; import {safeParseJSON, toTitleCase} from '@utils/helpers'; import {logError} from '@utils/log'; import type {CustomProfileFieldModel, CustomProfileAttributeModel} from '@database/models/server'; import type {CustomAttribute, CustomAttributeSet} from '@typings/api/custom_profile_attributes'; import type UserModel from '@typings/database/models/servers/user'; const displayUsernameMessages = defineMessages({ someone: { id: 'channel_loader.someone', defaultMessage: 'Someone', }, }); export function displayUsername(user?: UserProfile | UserModel | null, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) { let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, displayUsernameMessages.someone.id, displayUsernameMessages.someone.defaultMessage) : ''; if (user) { if (teammateDisplayNameSetting === Preferences.DISPLAY_PREFER_NICKNAME) { name = user.nickname || getFullName(user); } else if (teammateDisplayNameSetting === Preferences.DISPLAY_PREFER_FULL_NAME) { name = getFullName(user); } else { name = user.username; } if (!name || name.trim().length === 0) { name = user.username; } } return name; } export function displayGroupMessageName(users: Array, locale?: string, teammateDisplayNameSetting?: string, excludeUserId?: string) { const names: string[] = []; const sortUsernames = (a: string, b: string) => { return a.localeCompare(b, locale || DEFAULT_LOCALE, {numeric: true}); }; users.forEach((u) => { if (u.id !== excludeUserId) { names.push(displayUsername(u, locale, teammateDisplayNameSetting, false) || u.username); } }); return names.sort(sortUsernames).join(', ').trim(); } export function getFullName(user: UserProfile | UserModel): string { let firstName: string; let lastName: string; if ('lastName' in user) { firstName = user.firstName; lastName = user.lastName; } else { firstName = user.first_name; lastName = user.last_name; } if (firstName && lastName) { return `${firstName} ${lastName}`; } else if (firstName) { return firstName; } else if (lastName) { return lastName; } return ''; } export function getUserIdFromChannelName(knownUserId: string, channelName: string): string { const ids = channelName.split('__'); if (ids[0] === knownUserId) { return ids[1]; } return ids[0]; } export function isRoleInRoles(roles: string, role: string): boolean { const rolesArray = roles.split(' '); return rolesArray.includes(role); } export function isGuest(roles: string): boolean { return isRoleInRoles(roles, Permissions.SYSTEM_GUEST_ROLE); } export function isSystemAdmin(roles: string): boolean { return isRoleInRoles(roles, Permissions.SYSTEM_ADMIN_ROLE); } export function isChannelAdmin(roles: string): boolean { return isRoleInRoles(roles, Permissions.CHANNEL_ADMIN_ROLE); } export const getUsersByUsername = (users: UserModel[]) => { const usersByUsername: Dictionary = {}; for (const user of users) { usersByUsername[user.username] = user; } return usersByUsername; }; export const getUserTimezoneProps = (currentUser?: UserModel) => { if (currentUser?.timezone) { return { ...currentUser?.timezone, useAutomaticTimezone: currentUser?.timezone?.useAutomaticTimezone === 'true', }; } return { useAutomaticTimezone: true, automaticTimezone: '', manualTimezone: '', }; }; export const getUserTimezone = (user?: UserModel | UserProfile) => { return getTimezone(user?.timezone); }; export const getTimezone = (timezone?: UserTimezone | null) => { if (!timezone) { return ''; } const {useAutomaticTimezone} = timezone; let useAutomatic = useAutomaticTimezone; if (typeof useAutomaticTimezone === 'string') { useAutomatic = useAutomaticTimezone === 'true'; } if (useAutomatic) { return timezone.automaticTimezone; } return timezone.manualTimezone; }; export const getTimezoneRegion = (timezone: string): string => { if (timezone) { const split = timezone.split('/'); if (split.length > 1) { return split.pop()!.replace(/_/g, ' '); } } return timezone; }; export const getUserCustomStatus = (user?: UserModel | UserProfile): UserCustomStatus | undefined => { try { if (typeof user?.props?.customStatus === 'string') { return JSON.parse(user.props.customStatus); } return user?.props?.customStatus; } catch { return undefined; } }; export function isCustomStatusExpired(user?: UserModel | UserProfile) { if (!user) { return true; } const customStatus = getUserCustomStatus(user); if (!customStatus) { return true; } if (customStatus.duration === CustomStatusDurationEnum.DONT_CLEAR || !customStatus.duration) { return false; } const expiryTime = moment(customStatus.expires_at); const timezone = getUserTimezone(user); const currentTime = timezone ? moment.tz(timezone) : moment(); return currentTime.isSameOrAfter(expiryTime); } export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, updateStatus: (status: string) => void) { let translatedStatus; switch (status) { case General.DND: translatedStatus = intl.formatMessage({ id: 'mobile.set_status.dnd', defaultMessage: 'Do Not Disturb', }); break; case General.AWAY: translatedStatus = intl.formatMessage({ id: 'mobile.set_status.away', defaultMessage: 'Away', }); break; case General.OFFLINE: translatedStatus = intl.formatMessage({ id: 'mobile.set_status.offline', defaultMessage: 'Offline', }); break; case General.ONLINE: translatedStatus = intl.formatMessage({ id: 'mobile.set_status.online', defaultMessage: 'Online', }); break; default: translatedStatus = toTitleCase(status); break; } Alert.alert( intl.formatMessage({ id: 'mobile.reset_status.title_ooo', defaultMessage: 'Disable "Out Of Office"?', }), intl.formatMessage({ id: 'modal.manual_status.auto_responder.message_', defaultMessage: 'Would you like to switch your status to "{status}" and disable Automatic Replies?', }, {status: translatedStatus}), [{ text: intl.formatMessage({id: 'mobile.reset_status.alert_cancel', defaultMessage: 'Cancel'}), style: 'cancel', }, { text: intl.formatMessage({id: 'mobile.reset_status.alert_ok', defaultMessage: 'OK'}), onPress: () => updateStatus(status), }], ); } export function isBot(user: UserProfile | UserModel): boolean { return 'isBot' in user ? Boolean(user.isBot) : Boolean(user.is_bot); } export function isShared(user: UserProfile | UserModel): boolean { return ('remoteId' in user) ? Boolean(user.remoteId) : Boolean(user.remote_id); } export function isDeactivated(user: UserProfile | UserModel): boolean { return Boolean('deleteAt' in user ? user.deleteAt : user.delete_at); } export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] { const list = [...originalList]; for (let i = list.length - 1; i >= 0; i--) { if (list[i].id === userId) { list.splice(i, 1); return list; } } return list; } // Splits the term by a splitStr and composes a list of the parts of // the split concatenated with the rest, forming a set of suggesitons // matchable with startsWith // // E.g.: for "one.two.three" by "." it would yield // ["one.two.three", ".two.three", "two.three", ".three", "three"] export function getSuggestionsSplitBy(term: string, splitStr: string): string[] { const splitTerm = term.split(splitStr); const initialSuggestions = splitTerm.map((st, i) => splitTerm.slice(i).join(splitStr)); let suggestions: string[] = []; if (splitStr === ' ') { suggestions = initialSuggestions; } else { suggestions = initialSuggestions.reduce((acc, val) => { if (acc.length === 0) { acc.push(val); } else { acc.push(splitStr + val, val); } return acc; }, []); } return suggestions; } export function getSuggestionsSplitByMultiple(term: string, splitStrs: string[]): string[] { const suggestions = splitStrs.reduce((acc, val) => { getSuggestionsSplitBy(term, val).forEach((suggestion) => acc.add(suggestion)); return acc; }, new Set()); return [...suggestions]; } export function filterProfilesMatchingTerm(users: UserProfile[], term: string): UserProfile[] { const lowercasedTerm = term.toLowerCase(); let trimmedTerm = lowercasedTerm; if (trimmedTerm.startsWith('@')) { trimmedTerm = trimmedTerm.substring(1); } return users.filter((user: UserProfile) => { if (!user) { return false; } const profileSuggestions: string[] = []; const usernameSuggestions = getSuggestionsSplitByMultiple((user.username || '').toLowerCase(), General.AUTOCOMPLETE_SPLIT_CHARACTERS); profileSuggestions.push(...usernameSuggestions); const first = (user.first_name || '').toLowerCase(); const last = (user.last_name || '').toLowerCase(); const full = first + ' ' + last; profileSuggestions.push(first, last, full); profileSuggestions.push((user.nickname || '').toLowerCase()); const email = (user.email || '').toLowerCase(); profileSuggestions.push(email); profileSuggestions.push((user.nickname || '').toLowerCase()); const split = email.split('@'); if (split.length > 1) { profileSuggestions.push(split[1]); } return profileSuggestions. filter((suggestion) => suggestion !== ''). some((suggestion) => suggestion.includes(trimmedTerm)); }); } export function getNotificationProps(user?: UserModel) { if (user && user.notifyProps) { return user.notifyProps; } const props: UserNotifyProps = { channel: 'true', comments: 'any', desktop: 'all', desktop_sound: 'true', email: 'true', first_name: (!user || !user.firstName) ? 'false' : 'true', mark_unread: 'all', mention_keys: user?.username ? `${user.username},@${user.username}` : '', highlight_keys: '', push: 'mention', push_status: 'online', push_threads: 'all', email_threads: 'all', calls_desktop_sound: 'true', calls_notification_sound: Ringtone.Calm, calls_mobile_sound: '', calls_mobile_notification_sound: '', }; return props; } export function getEmailInterval(enableEmailNotification: boolean, enableEmailBatching: boolean, emailIntervalPreference: number): number { const { INTERVAL_NEVER, INTERVAL_IMMEDIATE, INTERVAL_FIFTEEN_MINUTES, INTERVAL_HOUR, } = Preferences; const validValuesWithEmailBatching = [INTERVAL_IMMEDIATE, INTERVAL_NEVER, INTERVAL_FIFTEEN_MINUTES, INTERVAL_HOUR]; const validValuesWithoutEmailBatching = [INTERVAL_IMMEDIATE, INTERVAL_NEVER]; if (!enableEmailNotification) { return INTERVAL_NEVER; } else if (enableEmailBatching && validValuesWithEmailBatching.indexOf(emailIntervalPreference) === -1) { // When email batching is enabled, the default interval is 15 minutes return INTERVAL_FIFTEEN_MINUTES; } else if (!enableEmailBatching && validValuesWithoutEmailBatching.indexOf(emailIntervalPreference) === -1) { // When email batching is not enabled, the default interval is immediately return INTERVAL_IMMEDIATE; } else if (enableEmailNotification && emailIntervalPreference === INTERVAL_NEVER) { // When email notification is enabled, the default interval is immediately return INTERVAL_IMMEDIATE; } return emailIntervalPreference; } export const getEmailIntervalTexts = (interval: string) => { const intervalTexts: Record = { [Preferences.INTERVAL_FIFTEEN_MINUTES]: {id: 'notification_settings.email.fifteenMinutes', defaultMessage: 'Every 15 minutes'}, [Preferences.INTERVAL_HOUR]: {id: 'notification_settings.email.everyHour', defaultMessage: 'Every hour'}, [Preferences.INTERVAL_IMMEDIATE]: {id: 'notification_settings.email.immediately', defaultMessage: 'Immediately'}, [Preferences.INTERVAL_NEVER]: {id: 'notification_settings.email.never', defaultMessage: 'Never'}, }; return intervalTexts[interval]; }; export const getLastPictureUpdate = (user: UserModel | UserProfile) => { if ('isBot' in user) { return user.isBot ? user.props?.bot_last_icon_update : user.lastPictureUpdate || 0; } return user.is_bot ? user.bot_last_icon_update : user.last_picture_update || 0; }; /** * Sorts custom profile attributes by their sort_order property, falling back to name comparison. * Attributes with undefined sort_order are placed last. * @param a First CustomAttribute to compare * @param b Second CustomAttribute to compare * @returns Negative if a comes first, positive if b comes first, 0 if equal */ export const sortCustomProfileAttributes = (a: CustomAttribute, b: CustomAttribute): number => { const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER; const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER; return orderA === orderB ? a.name.localeCompare(b.name) : orderA - orderB; }; /** * Converts an array of custom profile attributes to a map of attributes by their id. * @param attributesToConvert - The array of custom profile attributes to convert * @returns A map of attributes by their id */ export const convertToAttributesMap = (attributesToConvert: CustomAttributeSet | CustomAttribute[]): CustomAttributeSet => { if (!Array.isArray(attributesToConvert)) { return attributesToConvert as CustomAttributeSet; } const attributesMap: CustomAttributeSet = {}; attributesToConvert.forEach((attr) => { attributesMap[attr.id] = attr; }); return attributesMap; }; export const getDisplayType = (field: CustomProfileFieldModel): string => { if (field.type === 'text' && field.attrs?.value_type !== undefined && field.attrs.value_type !== '') { return field.attrs.value_type; } return field.type; }; /** * Get the options from a select or multiselect field as a map of option IDs to option names * @param field - The custom profile field * @returns A record where keys are option IDs and values are option names */ export const getFieldOptions = (field: CustomProfileFieldModel): Record => { if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) { return {}; } const options = field.attrs.options as Array<{id: string; name: string}> | undefined; if (!options || !Array.isArray(options)) { return {}; } const optionsMap: Record = {}; options.forEach((option) => { if (option.id && option.name) { optionsMap[option.id] = option.name; } }); return optionsMap; }; /** * Convert option IDs to display values for select/multiselect fields * @param value - The stored value (option ID or comma-separated IDs) * @param fieldType - The field type ('select' or 'multiselect') * @param optionsMap - Map of option IDs to option names * @returns The display value with option names */ const convertOptionsToDisplayValue = (value: string, fieldType: string, optionsMap: Record): string => { if (!value || !optionsMap) { return value; } if (fieldType === 'select') { // Single select: just return the option name for the ID return optionsMap[value] || value; } if (fieldType === 'multiselect') { // Multi-select: handle comma-separated IDs or JSON array let optionIds: string[] = []; // Try parsing as JSON array first try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { optionIds = parsed; } else { // Fallback to comma-separated string optionIds = value.split(',').map((id) => id.trim()); } } catch { // Fallback to comma-separated string optionIds = value.split(',').map((id) => id.trim()); } // Convert IDs to names and filter out empty values const optionNames = optionIds. map((id) => optionsMap[id] || id). filter((name) => name.trim() !== ''); return optionNames.join(', '); } return value; }; /** * Convert custom profile attributes to the UI-ready CustomAttribute format * @param attributes - Array of custom profile attribute models * @param fields - Array of custom profile field models * @param sortFn - Optional sort function * @returns Array of formatted CustomAttribute objects */ export const convertProfileAttributesToCustomAttributes = ( attributes: CustomProfileAttributeModel[] | null | undefined, fields: CustomProfileFieldModel[] | null | undefined, sortFn?: (a: CustomAttribute, b: CustomAttribute) => number, useDisplayType: boolean = false, ): CustomAttribute[] => { if (!attributes?.length) { return []; } const fieldsMap = new Map(); const fieldOptionsMap = new Map>(); fields?.forEach((field) => { const customType = useDisplayType ? getDisplayType(field) : field.type; fieldsMap.set(field.id, { name: field.name, type: customType, sort_order: field.attrs?.sort_order ?? Number.MAX_SAFE_INTEGER, originalField: field, }); // Store options map for select/multiselect fields if (field.type === 'select' || field.type === 'multiselect') { fieldOptionsMap.set(field.id, getFieldOptions(field)); } }); const customAttrs = attributes.map((attr) => { const field = fieldsMap.get(attr.fieldId); let displayValue = attr.value; // Convert option IDs to names for select/multiselect fields if (field && (field.type === 'select' || field.type === 'multiselect')) { const optionsMap = fieldOptionsMap.get(attr.fieldId); if (optionsMap && Object.keys(optionsMap).length > 0) { displayValue = convertOptionsToDisplayValue(attr.value, field.type, optionsMap); } } return ({ id: attr.fieldId, name: field?.name || attr.fieldId, type: field?.type || 'text', value: displayValue, sort_order: field?.sort_order ?? Number.MAX_SAFE_INTEGER, }); }); // Sort if a sort function is provided return sortFn ? customAttrs.sort(sortFn) : customAttrs; }; /** * Convert display values back to option IDs for select/multiselect fields * @param displayValue - The display value (option names) * @param fieldType - The field type ('select' or 'multiselect') * @param optionsMap - Map of option IDs to option names * @returns The option IDs as string (select) or JSON array string (multiselect) */ export const convertDisplayValueToOptionIds = (displayValue: string, fieldType: string, optionsMap: Record): string => { if (!displayValue || !optionsMap) { return displayValue; } // Create reverse map (option names to IDs) const reverseOptionsMap: Record = {}; Object.entries(optionsMap).forEach(([id, name]) => { reverseOptionsMap[name] = id; }); if (fieldType === 'select') { // Single select: return the option ID for the name return reverseOptionsMap[displayValue] || displayValue; } if (fieldType === 'multiselect') { // Multi-select: split display names and convert to IDs const optionNames = displayValue.split(',').map((name) => name.trim()).filter((name) => name !== ''); const optionIds = optionNames.map((name) => reverseOptionsMap[name] || name); return JSON.stringify(optionIds); } return displayValue; }; /** * Get selected option IDs from a custom attribute value * @param value - The stored value (option ID or JSON array string) * @param fieldType - The field type ('select' or 'multiselect') * @returns Array of selected option IDs */ export const getSelectedOptionIds = (value: string, fieldType: string): string[] => { if (!value) { return []; } if (fieldType === 'select') { return [value]; } if (fieldType === 'multiselect') { try { const parsed: unknown = safeParseJSON(value); if (Array.isArray(parsed)) { return parsed; } // Fallback to comma-separated string return value.split(',').map((id) => id.trim()).filter((id) => id !== ''); } catch (error) { return value.split(',').map((id) => id.trim()).filter((id) => id !== ''); } } return []; }; /** * Format field options for use with AutocompleteSelector component * @param field - The custom profile field * @returns Array of DialogOption objects for the selector */ export const formatOptionsForSelector = (field: CustomProfileFieldModel): DialogOption[] => { if (!field.attrs || (field.type !== 'select' && field.type !== 'multiselect')) { return []; } const options = field.attrs.options as Array<{id: string; name: string}> | undefined; if (!options || !Array.isArray(options)) { return []; } return options.map((option) => ({ text: option.name, value: option.id, })); }; /** * Convert selected option IDs to the format expected by the server * @param value - The stored value (option ID or JSON array string) * @param fieldType - The field type ('select' or 'multiselect') * @returns String for select, array for multiselect */ export const convertValueForServer = (value: string, fieldType: string): string|string[] => { if (fieldType !== 'multiselect') { return value; } if (!value) { return []; } try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { return parsed; } } catch { const parsed = value.split(',').map((id) => id.trim()).filter((id) => id !== ''); return parsed; } return []; }; /** * Convert server response values to the format expected by the UI * @param value - The value from server (string or array) * @param fieldType - The field type ('select' or 'multiselect') * @returns String representation for UI storage */ export const convertValueFromServer = (value: string | string[], fieldType: string): string => { if (value === null || value === undefined) { return ''; } if (fieldType === 'select') { return Array.isArray(value) ? (value[0] || '') : String(value); } if (fieldType === 'multiselect') { if (Array.isArray(value)) { try { return JSON.stringify(value); } catch (error) { logError('Error converting value from server to string', error); return ''; } } return String(value); } return String(value); }; export const isCustomFieldSamlLinked = (customField?: CustomProfileFieldModel): boolean => { return Boolean(customField?.attrs?.saml); };