diff --git a/keyserver/src/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js index a779a4f56..f6144f240 100644 --- a/keyserver/src/fetchers/upload-fetchers.js +++ b/keyserver/src/fetchers/upload-fetchers.js @@ -1,407 +1,407 @@ // @flow import ip from 'internal-ip'; import _keyBy from 'lodash/fp/keyBy.js'; import type { Media, Image, EncryptedImage } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadFetchMediaResult, ThreadFetchMediaRequest, } from 'lib/types/thread-types.js'; import { makeBlobServiceURI } from 'lib/utils/blob-service.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; import { getAndAssertCommAppURLFacts } from '../utils/urls.js'; type UploadInfo = { content: Buffer, mime: string, }; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` SELECT content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } return { content, mime }; } async function fetchUploadChunk( id: string, secret: string, pos: number, len: number, ): Promise { // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed const query = SQL` SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return { content, mime, }; } // Returns total size in bytes. async function getUploadSize(id: string, secret: string): Promise { const query = SQL` SELECT LENGTH(content) AS length, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { length, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return length; } function getUploadURL(id: string, secret: string): string { const { baseDomain, basePath } = getAndAssertCommAppURLFacts(); const uploadPath = `${basePath}upload/${id}/${secret}`; if (isDev) { const ipV4 = ip.v4.sync() || 'localhost'; const port = parseInt(process.env.PORT, 10) || 3000; return `http://${ipV4}:${port}${uploadPath}`; } return `${baseDomain}${uploadPath}`; } function makeUploadURI(blobHash: ?string, id: string, secret: string): string { if (blobHash) { return makeBlobServiceURI(blobHash); } return getUploadURL(id, secret); } function imagesFromRow(row: Object): Image | EncryptedImage { const uploadExtra = JSON.parse(row.uploadExtra); const { width, height, blobHash, thumbHash } = uploadExtra; const { uploadType: type, uploadSecret: secret } = row; const id = row.uploadID.toString(); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, id, secret); const isEncrypted = !!uploadExtra.encryptionKey; if (type !== 'photo') { throw new ServerError('invalid_parameters'); } if (!isEncrypted) { return { id, type: 'photo', uri, dimensions, thumbHash }; } return { id, type: 'encrypted_photo', - holder: uri, + blobURI: uri, dimensions, thumbHash, encryptionKey: uploadExtra.encryptionKey, }; } async function fetchImages( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [result] = await dbQuery(query); return result.map(imagesFromRow); } async function fetchMediaForThread( viewer: Viewer, request: ThreadFetchMediaRequest, ): Promise { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT content.photo AS uploadID, u.secret AS uploadSecret, u.type AS uploadType, u.extra AS uploadExtra, u.container, u.creation_time, NULL AS thumbnailID, NULL AS thumbnailUploadSecret, NULL AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS(photo INT PATH "$") ) content ON 1 LEFT JOIN uploads u ON u.id = content.photo LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.IMAGES} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE UNION SELECT content.media AS uploadID, uv.secret AS uploadSecret, uv.type AS uploadType, uv.extra AS uploadExtra, uv.container, uv.creation_time, content.thumbnail AS thumbnailID, ut.secret AS thumbnailUploadSecret, ut.extra AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS( media INT PATH "$.uploadID", thumbnail INT PATH "$.thumbnailUploadID" ) ) content ON 1 LEFT JOIN uploads uv ON uv.id = content.media LEFT JOIN uploads ut ON ut.id = content.thumbnail LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.MULTIMEDIA} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY creation_time DESC LIMIT ${request.limit} OFFSET ${request.offset} `; const [uploads] = await dbQuery(query); const media = uploads.map(upload => { const { uploadID, uploadType, uploadSecret, uploadExtra } = upload; const { width, height, encryptionKey, blobHash, thumbHash } = JSON.parse(uploadExtra); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, uploadID, uploadSecret); if (uploadType === 'photo') { if (encryptionKey) { return { type: 'encrypted_photo', id: uploadID.toString(), - holder: uri, + blobURI: uri, encryptionKey, dimensions, thumbHash, }; } return { type: 'photo', id: uploadID.toString(), uri, dimensions, thumbHash, }; } const { thumbnailID, thumbnailUploadSecret, thumbnailUploadExtra } = upload; const { encryptionKey: thumbnailEncryptionKey, blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash, } = JSON.parse(thumbnailUploadExtra); const thumbnailURI = makeUploadURI( thumbnailBlobHash, thumbnailID, thumbnailUploadSecret, ); if (encryptionKey) { return { type: 'encrypted_video', id: uploadID.toString(), - holder: uri, + blobURI: uri, encryptionKey, dimensions, thumbnailID, - thumbnailHolder: thumbnailURI, + thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }; } return { type: 'video', id: uploadID.toString(), uri, dimensions, thumbnailID, thumbnailURI, thumbnailThumbHash, }; }); return { media }; } async function fetchUploadsForMessage( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploadIDs = getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [uploads] = await dbQuery(query); return uploads; } async function fetchMediaFromMediaMessageContent( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploads = await fetchUploadsForMessage(viewer, mediaMessageContents); return constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, uploads, ); } function constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents: $ReadOnlyArray, uploadRows: $ReadOnlyArray, ): $ReadOnlyArray { const uploadMap = _keyBy('uploadID')(uploadRows); const media: Media[] = []; for (const mediaMessageContent of mediaMessageContents) { const primaryUploadID = mediaMessageContent.uploadID; const primaryUpload = uploadMap[primaryUploadID]; const uploadExtra = JSON.parse(primaryUpload.uploadExtra); const { width, height, loop, blobHash, encryptionKey, thumbHash } = uploadExtra; const dimensions = { width, height }; const primaryUploadURI = makeUploadURI( blobHash, primaryUploadID, primaryUpload.uploadSecret, ); if (mediaMessageContent.type === 'photo') { if (encryptionKey) { media.push({ type: 'encrypted_photo', id: primaryUploadID, - holder: primaryUploadURI, + blobURI: primaryUploadURI, encryptionKey, dimensions, thumbHash, }); } else { media.push({ type: 'photo', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbHash, }); } continue; } const thumbnailUploadID = mediaMessageContent.thumbnailUploadID; const thumbnailUpload = uploadMap[thumbnailUploadID]; const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra); const { blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash } = thumbnailUploadExtra; const thumbnailUploadURI = makeUploadURI( thumbnailBlobHash, thumbnailUploadID, thumbnailUpload.uploadSecret, ); if (encryptionKey) { const video = { type: 'encrypted_video', id: primaryUploadID, - holder: primaryUploadURI, + blobURI: primaryUploadURI, encryptionKey, dimensions, thumbnailID: thumbnailUploadID, - thumbnailHolder: thumbnailUploadURI, + thumbnailBlobURI: thumbnailUploadURI, thumbnailEncryptionKey: thumbnailUploadExtra.encryptionKey, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } else { const video = { type: 'video', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbnailID: thumbnailUploadID, thumbnailURI: thumbnailUploadURI, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } } return media; } export { fetchUpload, fetchUploadChunk, getUploadSize, getUploadURL, makeUploadURI, imagesFromRow, fetchImages, fetchMediaForThread, fetchMediaFromMediaMessageContent, constructMediaFromMediaMessageContentsAndUploadRows, }; diff --git a/lib/media/media-utils.js b/lib/media/media-utils.js index 9faeadfcd..a2ed4507e 100644 --- a/lib/media/media-utils.js +++ b/lib/media/media-utils.js @@ -1,114 +1,168 @@ // @flow import invariant from 'invariant'; +import { + FUTURE_CODE_VERSION, + hasMinCodeVersion, +} from '../shared/version-utils.js'; +import type { PlatformDetails } from '../types/device-types.js'; import type { EncryptedImage, EncryptedVideo, Media, } from '../types/media-types.js'; import type { MultimediaMessageInfo, RawMultimediaMessageInfo, } from '../types/message-types.js'; +import type { RawMediaMessageInfo } from '../types/messages/media.js'; import { isBlobServiceURI, getBlobFetchableURL, blobHashFromBlobServiceURI, } from '../utils/blob-service.js'; const maxDimensions = Object.freeze({ width: 1920, height: 1920 }); function contentStringForMediaArray(media: $ReadOnlyArray): string { if (media.length === 0) { return 'corrupted media'; } else if (media.length === 1) { const type = media[0].type.replace('encrypted_', ''); return `a ${type}`; } let firstType; for (const single of media) { if (!firstType) { firstType = single.type; } if (firstType === single.type) { continue; } else { return 'some media'; } } invariant(firstType, 'there should be some media'); firstType = firstType.replace('encrypted_', ''); if (firstType === 'photo') { firstType = 'image'; } return `some ${firstType}s`; } function isMediaBlobServiceHosted(media: Media): boolean { return ( (!!media.uri && isBlobServiceURI(media.uri)) || (!!media.holder && isBlobServiceURI(media.holder)) || (!!media.thumbnailURI && isBlobServiceURI(media.thumbnailURI)) || (!!media.thumbnailHolder && isBlobServiceURI(media.thumbnailHolder)) || (!!media.blobURI && isBlobServiceURI(media.blobURI)) || (!!media.thumbnailBlobURI && isBlobServiceURI(media.thumbnailBlobURI)) ); } +/** Clients before FUTURE_CODE_VERSION understand only `holder` + * and `thumbnailHolder` fields, while newer clients understand `blobURI` + * and `thumbnailBlobURI`. This function formats the multimedia message + * to be understandable by clients based on their version + */ +function versionSpecificMediaMessageFormat( + rawMessageInfo: RawMediaMessageInfo, + platformDetails: ?PlatformDetails, +): RawMediaMessageInfo { + const isBlobURISupported = hasMinCodeVersion(platformDetails, { + native: FUTURE_CODE_VERSION, + }); + const media = rawMessageInfo.media.map(singleMedia => { + if (singleMedia.type === 'photo' || singleMedia.type === 'video') { + return singleMedia; + } + + if (singleMedia.type === 'encrypted_photo') { + const { blobURI, holder, ...photoMedia } = singleMedia; + const mediaURI = encryptedMediaBlobURI(singleMedia); + return isBlobURISupported + ? { ...photoMedia, blobURI: mediaURI } + : { ...photoMedia, holder: mediaURI }; + } + + // encrypted_video + const { + blobURI, + holder, + thumbnailBlobURI, + thumbnailHolder, + ...videoMedia + } = singleMedia; + const mediaURI = encryptedMediaBlobURI(singleMedia); + const thumbnailURI = encryptedVideoThumbnailBlobURI(singleMedia); + + return isBlobURISupported + ? { ...videoMedia, blobURI: mediaURI, thumbnailBlobURI: thumbnailURI } + : { ...videoMedia, holder: mediaURI, thumbnailHolder: thumbnailURI }; + }); + + return { + ...rawMessageInfo, + media, + }; +} + function fetchableMediaURI(uri: string): string { if (isBlobServiceURI(uri)) { const blobHash = blobHashFromBlobServiceURI(uri); return getBlobFetchableURL(blobHash); } return uri; } function multimediaMessagePreview( messageInfo: MultimediaMessageInfo | RawMultimediaMessageInfo, ): string { const mediaContentString = contentStringForMediaArray(messageInfo.media); return `sent ${mediaContentString}`; } const localUploadPrefix = 'localUpload'; function isLocalUploadID(id: string): boolean { return id.startsWith(localUploadPrefix); } let nextLocalUploadID = 0; function getNextLocalUploadID(): string { return `${localUploadPrefix}${nextLocalUploadID++}`; } /** * Returns effective multimedia blob URI for encrypted image or video */ function encryptedMediaBlobURI(media: EncryptedImage | EncryptedVideo): string { const uri = media.blobURI ?? media.holder; invariant(uri, 'encrypted media has no blob URI'); return uri; } /** * Returns effective thumbnail blob URI for encrypted video */ function encryptedVideoThumbnailBlobURI(media: EncryptedVideo): string { const uri = media.thumbnailBlobURI ?? media.thumbnailHolder; invariant(uri, 'encrypted thumbnail has no blob URI'); return uri; } export { maxDimensions, contentStringForMediaArray, multimediaMessagePreview, isLocalUploadID, isMediaBlobServiceHosted, + versionSpecificMediaMessageFormat, getNextLocalUploadID, fetchableMediaURI, encryptedMediaBlobURI, encryptedVideoThumbnailBlobURI, }; diff --git a/lib/shared/messages/multimedia-message-spec.js b/lib/shared/messages/multimedia-message-spec.js index f94a32c67..a0447c517 100644 --- a/lib/shared/messages/multimedia-message-spec.js +++ b/lib/shared/messages/multimedia-message-spec.js @@ -1,358 +1,364 @@ // @flow import invariant from 'invariant'; import { pushTypes, type MessageSpec, type MessageTitleParam, type RawMessageInfoFromServerDBRowParams, } from './message-spec.js'; import { joinResult } from './utils.js'; import { + versionSpecificMediaMessageFormat, isMediaBlobServiceHosted, contentStringForMediaArray, multimediaMessagePreview, } from '../../media/media-utils.js'; import type { PlatformDetails } from '../../types/device-types.js'; import { assertMessageType, messageTypes, } from '../../types/message-types-enum.js'; import { isMediaMessageType, rawMultimediaMessageInfoValidator, } from '../../types/message-types.js'; import type { MessageInfo, RawMessageInfo, ClientDBMessageInfo, } from '../../types/message-types.js'; import type { ImagesMessageData, RawImagesMessageInfo, ImagesMessageInfo, } from '../../types/messages/images.js'; import type { MediaMessageData, MediaMessageInfo, RawMediaMessageInfo, } from '../../types/messages/media.js'; import { getMediaMessageServerDBContentsFromMedia } from '../../types/messages/media.js'; import type { RawUnsupportedMessageInfo } from '../../types/messages/unsupported.js'; import type { NotifTexts } from '../../types/notif-types.js'; import type { ThreadInfo } from '../../types/thread-types.js'; import type { RelativeUserInfo } from '../../types/user-types.js'; import { ET } from '../../utils/entity-text.js'; import { translateClientDBMediaInfosToMedia, translateClientDBMediaInfoToImage, } from '../../utils/message-ops-utils.js'; import { createMediaMessageInfo } from '../message-utils.js'; import { threadIsGroupChat } from '../thread-utils.js'; import { FUTURE_CODE_VERSION, hasMinCodeVersion } from '../version-utils.js'; export const multimediaMessageSpec: MessageSpec< MediaMessageData | ImagesMessageData, RawMediaMessageInfo | RawImagesMessageInfo, MediaMessageInfo | ImagesMessageInfo, > = Object.freeze({ messageContentForServerDB( data: | MediaMessageData | ImagesMessageData | RawMediaMessageInfo | RawImagesMessageInfo, ): string { if (data.type === messageTypes.MULTIMEDIA) { return JSON.stringify( getMediaMessageServerDBContentsFromMedia(data.media), ); } const mediaIDs = data.media.map(media => parseInt(media.id, 10)); return JSON.stringify(mediaIDs); }, messageContentForClientDB( data: RawMediaMessageInfo | RawImagesMessageInfo, ): string { return this.messageContentForServerDB(data); }, rawMessageInfoFromClientDB( clientDBMessageInfo: ClientDBMessageInfo, ): RawImagesMessageInfo | RawMediaMessageInfo { const messageType = assertMessageType(parseInt(clientDBMessageInfo.type)); invariant( isMediaMessageType(messageType), 'message must be of type IMAGES or MULTIMEDIA', ); invariant( clientDBMessageInfo.media_infos !== null && clientDBMessageInfo.media_infos !== undefined, `media_infos must be defined`, ); let rawMessageInfo: RawImagesMessageInfo | RawMediaMessageInfo = messageType === messageTypes.IMAGES ? { type: messageTypes.IMAGES, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: clientDBMessageInfo.media_infos?.map( translateClientDBMediaInfoToImage, ) ?? [], } : { type: messageTypes.MULTIMEDIA, threadID: clientDBMessageInfo.thread, creatorID: clientDBMessageInfo.user, time: parseInt(clientDBMessageInfo.time), media: translateClientDBMediaInfosToMedia(clientDBMessageInfo), }; if (clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, localID: clientDBMessageInfo.local_id, }; } if (clientDBMessageInfo.id !== clientDBMessageInfo.local_id) { rawMessageInfo = { ...rawMessageInfo, id: clientDBMessageInfo.id, }; } return rawMessageInfo; }, messageTitle({ messageInfo, }: MessageTitleParam) { const creator = ET.user({ userInfo: messageInfo.creator }); const preview = multimediaMessagePreview(messageInfo); return ET`${creator} ${preview}`; }, rawMessageInfoFromServerDBRow( row: Object, params: RawMessageInfoFromServerDBRowParams, ): RawMediaMessageInfo | RawImagesMessageInfo { const { localID, media } = params; invariant(media, 'Media should be provided'); return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID, time: row.time, }); }, createMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, creator: RelativeUserInfo, ): ?(MediaMessageInfo | ImagesMessageInfo) { if (rawMessageInfo.type === messageTypes.IMAGES) { let messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { let messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo = { ...messageInfo, id: rawMessageInfo.id }; } if (rawMessageInfo.localID) { messageInfo = { ...messageInfo, localID: rawMessageInfo.localID }; } return messageInfo; } return undefined; }, rawMessageInfoFromMessageData( messageData: MediaMessageData | ImagesMessageData, id: ?string, ): RawMediaMessageInfo | RawImagesMessageInfo { const { sidebarCreation, ...rest } = messageData; if (rest.type === messageTypes.IMAGES && id) { return ({ ...rest, id }: RawImagesMessageInfo); } else if (rest.type === messageTypes.IMAGES) { return ({ ...rest }: RawImagesMessageInfo); } else if (id) { return ({ ...rest, id }: RawMediaMessageInfo); } else { return ({ ...rest }: RawMediaMessageInfo); } }, shimUnsupportedMessageInfo( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, platformDetails: ?PlatformDetails, ): RawMediaMessageInfo | RawImagesMessageInfo | RawUnsupportedMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { return rawMessageInfo; } - const containsBlobServiceMedia = rawMessageInfo.media.some( + const messageInfo = versionSpecificMediaMessageFormat( + rawMessageInfo, + platformDetails, + ); + + const containsBlobServiceMedia = messageInfo.media.some( isMediaBlobServiceHosted, ); - const containsEncryptedMedia = rawMessageInfo.media.some( + const containsEncryptedMedia = messageInfo.media.some( media => media.type === 'encrypted_photo' || media.type === 'encrypted_video', ); if ( !containsBlobServiceMedia && !containsEncryptedMedia && hasMinCodeVersion(platformDetails, { native: 158 }) ) { - return rawMessageInfo; + return messageInfo; } if ( !containsBlobServiceMedia && hasMinCodeVersion(platformDetails, { native: 205 }) ) { - return rawMessageInfo; + return messageInfo; } if (hasMinCodeVersion(platformDetails, { native: FUTURE_CODE_VERSION })) { - return rawMessageInfo; + return messageInfo; } - const { id } = rawMessageInfo; + const { id } = messageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, - threadID: rawMessageInfo.threadID, - creatorID: rawMessageInfo.creatorID, - time: rawMessageInfo.time, - robotext: multimediaMessagePreview(rawMessageInfo), - unsupportedMessageInfo: rawMessageInfo, + threadID: messageInfo.threadID, + creatorID: messageInfo.creatorID, + time: messageInfo.time, + robotext: multimediaMessagePreview(messageInfo), + unsupportedMessageInfo: messageInfo, }; }, unshimMessageInfo( unwrapped: RawMediaMessageInfo | RawImagesMessageInfo, messageInfo: RawMessageInfo, ): ?RawMessageInfo { if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map(media => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } else if (unwrapped.type === messageTypes.MULTIMEDIA) { for (const media of unwrapped.media) { if (isMediaBlobServiceHosted(media)) { return messageInfo; } const { type } = media; if ( type !== 'photo' && type !== 'video' && type !== 'encrypted_photo' && type !== 'encrypted_video' ) { return messageInfo; } } } return undefined; }, async notificationTexts( messageInfos: $ReadOnlyArray, threadInfo: ThreadInfo, ): Promise { const media = []; for (const messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (const singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const creator = ET.user({ userInfo: messageInfos[0].creator }); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const thread = ET.thread({ display: 'shortName', threadInfo }); merged = ET`${body} to ${thread}`; } merged = ET`${creator} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: ET`${creator}`, }; }, notificationCollapseKey( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, ): string { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); }, generatesNotifs: async ( rawMessageInfo: RawMediaMessageInfo | RawImagesMessageInfo, messageData: MediaMessageData | ImagesMessageData, ) => (messageData.sidebarCreation ? undefined : pushTypes.NOTIF), includedInRepliesCount: true, validator: rawMultimediaMessageInfoValidator, }); // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, };