// Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { omit } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; import { SignalService as Proto } from '../protobuf'; import type { ReadonlyMessageAttributesType } from '../model-types.d'; import { isNotNil } from '../util/isNotNil'; import { format as formatPhoneNumber, normalize as normalizePhoneNumber, } from './PhoneNumber'; import type { AttachmentType, AttachmentForUIType, AttachmentWithHydratedData, LocalAttachmentV2Type, UploadedAttachmentType, } from './Attachment'; import { toLogFormat } from './errors'; import type { LoggerType } from './Logging'; import type { ServiceIdString } from './ServiceId'; import type { migrateDataToFileSystem } from '../util/attachments/migrateDataToFilesystem'; import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl'; type GenericEmbeddedContactType = { name?: Name; number?: ReadonlyArray; email?: ReadonlyArray; address?: ReadonlyArray; avatar?: AvatarType; organization?: string; // Populated by selector firstNumber?: string; serviceId?: ServiceIdString; }; export type EmbeddedContactType = GenericEmbeddedContactType; export type EmbeddedContactForUIType = GenericEmbeddedContactType; export type EmbeddedContactWithHydratedAvatar = GenericEmbeddedContactType; export type EmbeddedContactWithUploadedAvatar = GenericEmbeddedContactType; type Name = { givenName?: string; familyName?: string; prefix?: string; suffix?: string; middleName?: string; nickname?: string; }; export enum ContactFormType { HOME = 1, MOBILE = 2, WORK = 3, CUSTOM = 4, } export enum AddressType { HOME = 1, WORK = 2, CUSTOM = 3, } export type Phone = { value: string; type: ContactFormType; label?: string; }; export type Email = { value: string; type: ContactFormType; label?: string; }; export type PostalAddress = { type: AddressType; label?: string; street?: string; pobox?: string; neighborhood?: string; city?: string; region?: string; postcode?: string; country?: string; }; type GenericAvatar = { avatar: Attachment; isProfile: boolean; }; export type Avatar = GenericAvatar; export type AvatarForUI = GenericAvatar; export type AvatarWithHydratedData = GenericAvatar; export type UploadedAvatar = GenericAvatar; const DEFAULT_PHONE_TYPE = Proto.DataMessage.Contact.Phone.Type.HOME; const DEFAULT_EMAIL_TYPE = Proto.DataMessage.Contact.Email.Type.HOME; const DEFAULT_ADDRESS_TYPE = Proto.DataMessage.Contact.PostalAddress.Type.HOME; export function numberToPhoneType( type: number ): Proto.DataMessage.Contact.Phone.Type { if (type === Proto.DataMessage.Contact.Phone.Type.MOBILE) { return type; } if (type === Proto.DataMessage.Contact.Phone.Type.WORK) { return type; } if (type === Proto.DataMessage.Contact.Phone.Type.CUSTOM) { return type; } return DEFAULT_PHONE_TYPE; } export function numberToEmailType( type: number ): Proto.DataMessage.Contact.Email.Type { if (type === Proto.DataMessage.Contact.Email.Type.MOBILE) { return type; } if (type === Proto.DataMessage.Contact.Email.Type.WORK) { return type; } if (type === Proto.DataMessage.Contact.Email.Type.CUSTOM) { return type; } return DEFAULT_EMAIL_TYPE; } export function numberToAddressType( type: number ): Proto.DataMessage.Contact.PostalAddress.Type { if (type === Proto.DataMessage.Contact.PostalAddress.Type.WORK) { return type; } if (type === Proto.DataMessage.Contact.PostalAddress.Type.CUSTOM) { return type; } return DEFAULT_ADDRESS_TYPE; } export function embeddedContactSelector( contact: ReadonlyDeep, options: { regionCode?: string; firstNumber?: string; serviceId?: ServiceIdString; } ): ReadonlyDeep { const { firstNumber, serviceId, regionCode } = options; const { avatar } = contact; let avatarForUI: EmbeddedContactForUIType['avatar']; if (avatar && avatar.avatar) { if (avatar.avatar.error) { avatarForUI = undefined; } else { avatarForUI = { ...avatar, avatar: { ...avatar.avatar, path: avatar.avatar.path ? getLocalAttachmentUrl(avatar.avatar) : undefined, // `error` case is handled above isPermanentlyUndownloadable: false, }, }; } } return { ...contact, firstNumber, serviceId, avatar: avatarForUI, number: contact.number && contact.number.map(item => ({ ...item, value: formatPhoneNumber(item.value, { ourRegionCode: regionCode, }), })), }; } export function getDisplayName({ name, organization, }: ReadonlyDeep): string | undefined { // See https://github.com/signalapp/Signal-iOS-Private/blob/210a46037f12cdc6ad97ac6dceb64fbc43469f67/SignalServiceKit/Messages/Interactions/ContactShare/OWSContactName.swift#L87-L104 if (name?.nickname) { return name.nickname; } if (name?.givenName && name?.familyName) { return `${name.givenName} ${name.familyName}`; } if (organization) { return organization; } return undefined; } export function getName( contact: ReadonlyDeep ): string | undefined { const { name } = contact; const givenName = (name && name.givenName) || undefined; const familyName = (name && name.familyName) || undefined; return getDisplayName(contact) || givenName || familyName; } export function parseAndWriteAvatar( upgradeAttachment: typeof migrateDataToFileSystem ) { return async ( contact: EmbeddedContactType, context: { getRegionCode: () => string | undefined; logger: LoggerType; writeNewAttachmentData: ( data: Uint8Array ) => Promise; }, message: ReadonlyMessageAttributesType ): Promise => { const { getRegionCode, logger } = context; const { avatar } = contact; const contactWithUpdatedAvatar = avatar && avatar.avatar ? { ...contact, avatar: { ...avatar, avatar: await upgradeAttachment(avatar.avatar, context), }, } : omit(contact, ['avatar']); // eliminates empty numbers, emails, and addresses; adds type if not provided const parsedContact = parseContact(contactWithUpdatedAvatar, { regionCode: getRegionCode(), }); const error = _validate(parsedContact, { messageId: idForLogging(message), }); if (error) { logger.error( 'parseAndWriteAvatar: contact was malformed.', toLogFormat(error) ); } return parsedContact; }; } function parseContact( contact: EmbeddedContactType, { regionCode }: { regionCode: string | undefined } ): EmbeddedContactType { const boundParsePhone = (phoneNumber: Phone): Phone | undefined => parsePhoneItem(phoneNumber, { regionCode }); const skipEmpty = (arr: Array): Array | undefined => { const filtered: Array = arr.filter(isNotNil); return filtered.length ? filtered : undefined; }; const number = skipEmpty((contact.number || []).map(boundParsePhone)); const email = skipEmpty((contact.email || []).map(parseEmailItem)); const address = skipEmpty((contact.address || []).map(parseAddress)); let result = { ...omit(contact, ['avatar', 'number', 'email', 'address']), ...parseAvatar(contact.avatar), }; if (number) { result = { ...result, number }; } if (email) { result = { ...result, email }; } if (address) { result = { ...result, address }; } return result; } function idForLogging(message: ReadonlyMessageAttributesType): string { return `${message.source}.${message.sourceDevice} ${message.sent_at}`; } // Exported for testing export function _validate( contact: EmbeddedContactType, { messageId }: { messageId: string } ): Error | undefined { const { organization } = contact; if (!getDisplayName(contact) && !organization) { return new Error( `Message ${messageId}: Contact had neither 'displayName' nor 'organization'` ); } return undefined; } export function parsePhoneItem( item: Phone, { regionCode }: { regionCode: string | undefined } ): Phone | undefined { if (!item.value) { return undefined; } const value = regionCode ? normalizePhoneNumber(item.value, { regionCode }) : item.value; return { ...item, type: item.type || DEFAULT_PHONE_TYPE, value: value ?? item.value, }; } function parseEmailItem(item: Email): Email | undefined { if (!item.value) { return undefined; } return { ...item, type: item.type || DEFAULT_EMAIL_TYPE }; } function parseAddress(address: PostalAddress): PostalAddress | undefined { if (!address) { return undefined; } if ( !address.street && !address.pobox && !address.neighborhood && !address.city && !address.region && !address.postcode && !address.country ) { return undefined; } return { ...address, type: address.type || DEFAULT_ADDRESS_TYPE }; } function parseAvatar(avatar?: Avatar): { avatar: Avatar } | undefined { if (!avatar) { return undefined; } return { avatar: { ...avatar, isProfile: avatar.isProfile || false, }, }; }