diff --git a/lib/utils/message-ops-utils.js b/lib/utils/message-ops-utils.js index 002121eb6..0697fc4d9 100644 --- a/lib/utils/message-ops-utils.js +++ b/lib/utils/message-ops-utils.js @@ -1,356 +1,362 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; -import { contentStringForMediaArray } from '../media/media-utils.js'; +import { + contentStringForMediaArray, + encryptedMediaBlobURI, + encryptedVideoThumbnailBlobURI, +} from '../media/media-utils.js'; import { messageID } from '../shared/message-utils.js'; import { messageSpecs } from '../shared/messages/message-specs.js'; import type { EncryptedVideo, Media, ClientDBMediaInfo, Image, Video, } from '../types/media-types'; import { messageTypes, assertMessageType, } from '../types/message-types-enum.js'; import { type ClientDBMessageInfo, type RawMessageInfo, type MessageStoreOperation, type ClientDBMessageStoreOperation, type ClientDBThreadMessageInfo, type ThreadMessageInfo, type MessageStoreThreads, } from '../types/message-types.js'; import type { MediaMessageServerDBContent } from '../types/messages/media.js'; function translateMediaToClientDBMediaInfos( media: $ReadOnlyArray, ): $ReadOnlyArray { const clientDBMediaInfos = []; for (const m of media) { const type = m.type === 'encrypted_photo' ? 'photo' : m.type === 'encrypted_video' ? 'video' : m.type; const mediaURI = m.type === 'encrypted_photo' || m.type === 'encrypted_video' - ? m.holder + ? encryptedMediaBlobURI(m) : m.uri; clientDBMediaInfos.push({ id: m.id, uri: mediaURI, type: type, extras: JSON.stringify({ dimensions: m.dimensions, loop: type === 'video' ? m.loop : false, local_media_selection: m.localMediaSelection, encryption_key: m.encryptionKey, thumb_hash: m.thumbHash ?? undefined, }), }); if (m.type === 'video' || m.type === 'encrypted_video') { const thumbnailURI = - m.type === 'encrypted_video' ? m.thumbnailHolder : m.thumbnailURI; + m.type === 'encrypted_video' + ? encryptedVideoThumbnailBlobURI(m) + : m.thumbnailURI; clientDBMediaInfos.push({ id: m.thumbnailID, uri: thumbnailURI, type: 'photo', extras: JSON.stringify({ dimensions: m.dimensions, loop: false, encryption_key: m.thumbnailEncryptionKey, thumb_hash: m.thumbnailThumbHash ?? undefined, }), }); } } return clientDBMediaInfos; } function translateClientDBMediaInfoToImage( clientDBMediaInfo: ClientDBMediaInfo, ): Image { const { dimensions, local_media_selection, thumb_hash } = JSON.parse( clientDBMediaInfo.extras, ); if (!local_media_selection) { return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, thumbHash: thumb_hash, }; } return { id: clientDBMediaInfo.id, uri: clientDBMediaInfo.uri, type: 'photo', dimensions: dimensions, localMediaSelection: local_media_selection, thumbHash: thumb_hash, }; } function translateClientDBMediaInfosToMedia( clientDBMessageInfo: ClientDBMessageInfo, ): $ReadOnlyArray { if (parseInt(clientDBMessageInfo.type) === messageTypes.IMAGES) { if (!clientDBMessageInfo.media_infos) { return []; } return clientDBMessageInfo.media_infos.map( translateClientDBMediaInfoToImage, ); } if ( !clientDBMessageInfo.media_infos || clientDBMessageInfo.media_infos.length === 0 ) { return []; } const mediaInfos: $ReadOnlyArray = clientDBMessageInfo.media_infos; const mediaMap = _keyBy('id')(mediaInfos); if (!clientDBMessageInfo.content) { return []; } const messageContent: $ReadOnlyArray = JSON.parse(clientDBMessageInfo.content); const translatedMedia: Media[] = []; for (const media of messageContent) { if (media.type === 'photo') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions, encryption_key: encryptionKey, thumb_hash: thumbHash, } = extras; let image; if (encryptionKey) { image = { id: media.uploadID, type: 'encrypted_photo', - holder: mediaMap[media.uploadID].uri, + blobURI: mediaMap[media.uploadID].uri, dimensions, encryptionKey, thumbHash, }; } else { image = { id: media.uploadID, type: 'photo', uri: mediaMap[media.uploadID].uri, dimensions, thumbHash, }; } translatedMedia.push(image); } else if (media.type === 'video') { const extras = JSON.parse(mediaMap[media.uploadID].extras); const { dimensions, loop, local_media_selection: localMediaSelection, encryption_key: encryptionKey, } = extras; const { encryption_key: thumbnailEncryptionKey, thumb_hash: thumbnailThumbHash, } = JSON.parse(mediaMap[media.thumbnailUploadID].extras); if (encryptionKey) { const video: EncryptedVideo = { id: media.uploadID, type: 'encrypted_video', - holder: mediaMap[media.uploadID].uri, + blobURI: mediaMap[media.uploadID].uri, dimensions, loop, encryptionKey, thumbnailID: media.thumbnailUploadID, - thumbnailHolder: mediaMap[media.thumbnailUploadID].uri, + thumbnailBlobURI: mediaMap[media.thumbnailUploadID].uri, thumbnailEncryptionKey, thumbnailThumbHash, }; translatedMedia.push(video); } else { const video: Video = { id: media.uploadID, uri: mediaMap[media.uploadID].uri, type: 'video', dimensions, loop, thumbnailID: media.thumbnailUploadID, thumbnailURI: mediaMap[media.thumbnailUploadID].uri, thumbnailThumbHash, }; translatedMedia.push( localMediaSelection ? { ...video, localMediaSelection } : video, ); } } } return translatedMedia; } function translateRawMessageInfoToClientDBMessageInfo( rawMessageInfo: RawMessageInfo, ): ClientDBMessageInfo { return { id: messageID(rawMessageInfo), local_id: rawMessageInfo.localID ? rawMessageInfo.localID : null, thread: rawMessageInfo.threadID, user: rawMessageInfo.creatorID, type: rawMessageInfo.type.toString(), future_type: rawMessageInfo.type === messageTypes.UNSUPPORTED ? rawMessageInfo.unsupportedMessageInfo.type.toString() : null, time: rawMessageInfo.time.toString(), content: messageSpecs[rawMessageInfo.type].messageContentForClientDB?.( rawMessageInfo, ), media_infos: rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ? translateMediaToClientDBMediaInfos(rawMessageInfo.media) : null, }; } function translateClientDBMessageInfoToRawMessageInfo( clientDBMessageInfo: ClientDBMessageInfo, ): RawMessageInfo { return messageSpecs[ assertMessageType(parseInt(clientDBMessageInfo.type)) ].rawMessageInfoFromClientDB(clientDBMessageInfo); } function translateClientDBMessageInfosToRawMessageInfos( clientDBMessageInfos: $ReadOnlyArray, ): { +[id: string]: RawMessageInfo } { return Object.fromEntries( clientDBMessageInfos.map((dbMessageInfo: ClientDBMessageInfo) => [ dbMessageInfo.id, translateClientDBMessageInfoToRawMessageInfo(dbMessageInfo), ]), ); } type TranslatedThreadMessageInfos = { [threadID: string]: { startReached: boolean, lastNavigatedTo: number, lastPruned: number, }, }; function translateClientDBThreadMessageInfos( clientDBThreadMessageInfo: $ReadOnlyArray, ): TranslatedThreadMessageInfos { return Object.fromEntries( clientDBThreadMessageInfo.map((threadInfo: ClientDBThreadMessageInfo) => [ threadInfo.id, { startReached: threadInfo.start_reached === '1', lastNavigatedTo: parseInt(threadInfo.last_navigated_to), lastPruned: parseInt(threadInfo.last_pruned), }, ]), ); } function translateThreadMessageInfoToClientDBThreadMessageInfo( id: string, threadMessageInfo: ThreadMessageInfo, ): ClientDBThreadMessageInfo { const startReached = threadMessageInfo.startReached ? 1 : 0; const lastNavigatedTo = threadMessageInfo.lastNavigatedTo ?? 0; const lastPruned = threadMessageInfo.lastPruned ?? 0; return { id, start_reached: startReached.toString(), last_navigated_to: lastNavigatedTo.toString(), last_pruned: lastPruned.toString(), }; } function convertMessageStoreOperationsToClientDBOperations( messageStoreOperations: $ReadOnlyArray, ): $ReadOnlyArray { const convertedOperations = messageStoreOperations.map( messageStoreOperation => { if (messageStoreOperation.type === 'replace') { return { type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo( messageStoreOperation.payload.messageInfo, ), }; } if (messageStoreOperation.type !== 'replace_threads') { return messageStoreOperation; } const threadMessageInfo: MessageStoreThreads = messageStoreOperation.payload.threads; const dbThreadMessageInfos: ClientDBThreadMessageInfo[] = []; for (const threadID in threadMessageInfo) { dbThreadMessageInfos.push( translateThreadMessageInfoToClientDBThreadMessageInfo( threadID, threadMessageInfo[threadID], ), ); } if (dbThreadMessageInfos.length === 0) { return undefined; } return { type: 'replace_threads', payload: { threads: dbThreadMessageInfos, }, }; }, ); return convertedOperations.filter(Boolean); } function getPinnedContentFromClientDBMessageInfo( clientDBMessageInfo: ClientDBMessageInfo, ): string { const { media_infos } = clientDBMessageInfo; let pinnedContent; if (!media_infos || media_infos.length === 0) { pinnedContent = 'a message'; } else { const media = translateClientDBMediaInfosToMedia(clientDBMessageInfo); pinnedContent = contentStringForMediaArray(media); } return pinnedContent; } export { translateClientDBMediaInfoToImage, translateRawMessageInfoToClientDBMessageInfo, translateClientDBMessageInfoToRawMessageInfo, translateClientDBMessageInfosToRawMessageInfos, convertMessageStoreOperationsToClientDBOperations, translateClientDBMediaInfosToMedia, getPinnedContentFromClientDBMessageInfo, translateClientDBThreadMessageInfos, }; diff --git a/lib/utils/message-ops-utils.test.js b/lib/utils/message-ops-utils.test.js index 7e81d88be..547917fc1 100644 --- a/lib/utils/message-ops-utils.test.js +++ b/lib/utils/message-ops-utils.test.js @@ -1,548 +1,548 @@ // @flow import { translateRawMessageInfoToClientDBMessageInfo, translateClientDBMessageInfoToRawMessageInfo, translateClientDBMediaInfosToMedia, } from './message-ops-utils.js'; import type { ClientDBMessageInfo, RawSidebarSourceMessageInfo, } from '../types/message-types.js'; import type { RawAddMembersMessageInfo } from '../types/messages/add-members.js'; import type { RawChangeSettingsMessageInfo } from '../types/messages/change-settings.js'; import type { RawCreateEntryMessageInfo } from '../types/messages/create-entry.js'; import type { RawCreateSidebarMessageInfo } from '../types/messages/create-sidebar.js'; import type { RawCreateSubthreadMessageInfo } from '../types/messages/create-subthread.js'; import type { RawCreateThreadMessageInfo } from '../types/messages/create-thread.js'; import type { RawDeleteEntryMessageInfo } from '../types/messages/delete-entry.js'; import type { RawEditEntryMessageInfo } from '../types/messages/edit-entry.js'; import type { RawImagesMessageInfo } from '../types/messages/images.js'; import type { RawJoinThreadMessageInfo } from '../types/messages/join-thread.js'; import type { RawLeaveThreadMessageInfo } from '../types/messages/leave-thread.js'; import type { RawRemoveMembersMessageInfo } from '../types/messages/remove-members.js'; import type { RawRestoreEntryMessageInfo } from '../types/messages/restore-entry.js'; import type { RawTextMessageInfo } from '../types/messages/text.js'; import type { RawUpdateRelationshipMessageInfo } from '../types/messages/update-relationship.js'; test('TEXT: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawTextMessageInfo: RawTextMessageInfo = { type: 0, localID: 'local7', threadID: '85466', text: 'Hello world', creatorID: '85435', time: 1637788332565, id: '85551', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawTextMessageInfo), ), ).toStrictEqual(rawTextMessageInfo); }); test('TEXT (local): rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const localRawTextMessageInfo: RawTextMessageInfo = { type: 0, localID: 'local7', threadID: '85466', text: 'Hello world', creatorID: '85435', time: 1637788332565, }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(localRawTextMessageInfo), ), ).toStrictEqual(localRawTextMessageInfo); }); test('CREATE_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateThreadMessageInfo: RawCreateThreadMessageInfo = { type: 1, threadID: '85466', creatorID: '85435', time: 1637778853178, initialThreadState: { type: 6, name: null, parentThreadID: '1', color: '648CAA', memberIDs: ['256', '85435'], }, id: '85482', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateThreadMessageInfo), ), ).toStrictEqual(rawCreateThreadMessageInfo); }); test('ADD_MEMBER: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawAddMemberMessageInfo: RawAddMembersMessageInfo = { type: 2, threadID: '85946', creatorID: '83809', time: 1638236346010, addedUserIDs: ['256'], id: '85986', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawAddMemberMessageInfo), ), ).toStrictEqual(rawAddMemberMessageInfo); }); test('CREATE_SUB_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateSubthreadMessageInfo: RawCreateSubthreadMessageInfo = { type: 3, threadID: '85946', creatorID: '83809', time: 1638237345553, childThreadID: '85990', id: '85997', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawCreateSubthreadMessageInfo, ), ), ).toStrictEqual(rawCreateSubthreadMessageInfo); }); test('CHANGE_SETTINGS: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawChangeSettingsMessageInfo: RawChangeSettingsMessageInfo = { type: 4, threadID: '85946', creatorID: '83809', time: 1638236125774, field: 'color', value: '009cc8', id: '85972', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawChangeSettingsMessageInfo, ), ), ).toStrictEqual(rawChangeSettingsMessageInfo); }); test('REMOVE_MEMBERS: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawRemoveMembersMessageInfo: RawRemoveMembersMessageInfo = { type: 5, threadID: '85990', creatorID: '83809', time: 1638237832234, removedUserIDs: ['85435'], id: '86014', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawRemoveMembersMessageInfo), ), ).toStrictEqual(rawRemoveMembersMessageInfo); }); test('LEAVE_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawLeaveThreadMessageInfo: RawLeaveThreadMessageInfo = { type: 7, id: '86088', threadID: '85946', time: 1638238389038, creatorID: '85435', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawLeaveThreadMessageInfo), ), ).toStrictEqual(rawLeaveThreadMessageInfo); }); test('JOIN_THREAD: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawJoinThreadMessageInfo: RawJoinThreadMessageInfo = { type: 8, threadID: '86125', creatorID: '85435', time: 1638239691665, id: '86149', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawJoinThreadMessageInfo), ), ).toStrictEqual(rawJoinThreadMessageInfo); }); test('CREATE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateEntryMessageInfo: RawCreateEntryMessageInfo = { type: 9, threadID: '85630', creatorID: '85435', time: 1638239928303, entryID: '86151', date: '2021-11-29', text: 'Hello world', id: '86154', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateEntryMessageInfo), ), ).toStrictEqual(rawCreateEntryMessageInfo); }); test('EDIT_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawEditEntryMessageInfo: RawEditEntryMessageInfo = { type: 10, threadID: '85630', creatorID: '85435', time: 1638240110661, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86179', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawEditEntryMessageInfo), ), ).toStrictEqual(rawEditEntryMessageInfo); }); test('DELETE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawDeleteEntryMessageInfo: RawDeleteEntryMessageInfo = { type: 11, threadID: '85630', creatorID: '85435', time: 1638240286574, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86189', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawDeleteEntryMessageInfo), ), ).toStrictEqual(rawDeleteEntryMessageInfo); }); test('RESTORE_ENTRY: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawRestoreEntryMessageInfo: RawRestoreEntryMessageInfo = { type: 12, threadID: '85630', creatorID: '83809', time: 1638240605195, entryID: '86151', date: '2021-11-29', text: 'Hello universe', id: '86211', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawRestoreEntryMessageInfo), ), ).toStrictEqual(rawRestoreEntryMessageInfo); }); test('IMAGES: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawImagesMessageInfo: RawImagesMessageInfo = { type: 14, threadID: '85466', creatorID: '85435', time: 1637779260087, media: [ { id: '85504', type: 'photo', uri: 'http://localhost/comm/upload/85504/ba36cea2b5a796f6', dimensions: { width: 1920, height: 1281, }, thumbHash: 'some_thumb_hash', }, ], id: '85505', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawImagesMessageInfo), ), ).toStrictEqual(rawImagesMessageInfo); }); test('IMAGES (local): rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const localRawImagesMessageInfo: RawImagesMessageInfo = { type: 14, threadID: '85466', creatorID: '85435', time: 1637779260087, media: [ { id: '85504', type: 'photo', uri: 'http://localhost/comm/upload/85504/ba36cea2b5a796f6', dimensions: { width: 1920, height: 1281, }, thumbHash: 'some_thumb_hash', }, ], localID: 'local123', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(localRawImagesMessageInfo), ), ).toStrictEqual(localRawImagesMessageInfo); }); test('UPDATE_RELATIONSHIP: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawUpdateRelationshipMessageInfo: RawUpdateRelationshipMessageInfo = { type: 16, id: '85651', threadID: '85630', time: 1638235869690, creatorID: '83809', targetID: '85435', operation: 'request_accepted', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo( rawUpdateRelationshipMessageInfo, ), ), ).toStrictEqual(rawUpdateRelationshipMessageInfo); }); test('SIDEBAR_SOURCE: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawSidebarSourceMessageInfo: RawSidebarSourceMessageInfo = { type: 17, threadID: '86219', creatorID: '85435', time: 1638250532831, sourceMessage: { type: 0, id: '85486', threadID: '85466', time: 1637778853216, creatorID: '256', text: 'as you inevitably discover bugs, have feature requests, or design suggestions, feel free to message them to me in the app.', }, id: '86223', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawSidebarSourceMessageInfo), ), ).toStrictEqual(rawSidebarSourceMessageInfo); }); test('CREATE_SIDEBAR: rawMessageInfo -> clientDBMessageInfo -> rawMessageInfo', () => { const rawCreateSidebarMessageInfo: RawCreateSidebarMessageInfo = { type: 18, threadID: '86219', creatorID: '85435', time: 1638250532831, sourceMessageAuthorID: '256', initialThreadState: { name: 'as you inevitably discover ...', parentThreadID: '85466', color: 'ffffff', memberIDs: ['256', '85435'], }, id: '86224', }; expect( translateClientDBMessageInfoToRawMessageInfo( translateRawMessageInfoToClientDBMessageInfo(rawCreateSidebarMessageInfo), ), ).toStrictEqual(rawCreateSidebarMessageInfo); }); test('Test translateClientDBMediaInfosToMedia(...) with one video', () => { const clientDBMessageInfo: ClientDBMessageInfo = { id: 'local0', local_id: 'local0', thread: '90145', user: '90134', type: '15', future_type: null, time: '1665014145088', content: '[{"type":"video","uploadID":"localUpload0","thumbnailUploadID":"localUpload1"}]', media_infos: [ { id: 'localUpload0', uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'video', extras: '{"dimensions":{"height":1010,"width":576},"loop":false,"local_media_selection":{"step":"video_library","dimensions":{"height":1010,"width":576},"uri":"assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov","filename":"IMG_0007.MOV","mediaNativeID":"6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2/L0/001","duration":25.866666666666667,"selectTime":1665014144968,"sendTime":1665014144968,"retries":0}}', }, { id: 'localUpload1', uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'photo', extras: '{"dimensions":{"height":1010,"width":576},"loop":false}', }, ], }; const rawMessageInfo = { type: 15, threadID: '90145', creatorID: '90134', time: 1665014145088, media: [ { id: 'localUpload0', uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'video', dimensions: { height: 1010, width: 576 }, localMediaSelection: { step: 'video_library', dimensions: { height: 1010, width: 576 }, uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', filename: 'IMG_0007.MOV', mediaNativeID: '6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2/L0/001', duration: 25.866666666666667, selectTime: 1665014144968, sendTime: 1665014144968, retries: 0, }, loop: false, thumbnailThumbHash: undefined, thumbnailID: 'localUpload1', thumbnailURI: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', }, ], localID: 'local0', }; expect(translateClientDBMediaInfosToMedia(clientDBMessageInfo)).toStrictEqual( rawMessageInfo.media, ); }); test('Test translateClientDBMediaInfosToMedia() with encrypted photo', () => { const clientDBMessageInfo: ClientDBMessageInfo = { id: 'local0', local_id: 'local0', thread: '90145', user: '90134', type: '15', future_type: null, time: '1665014145088', content: '[{"type":"photo","uploadID":"localUpload0"}]', media_infos: [ { id: 'localUpload0', uri: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg', type: 'photo', extras: '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someKey","thumb_hash":"thumb"}', }, ], }; const rawMessageInfo = { type: 15, threadID: '90145', creatorID: '90134', time: 1665014145088, media: [ { id: 'localUpload0', type: 'encrypted_photo', - holder: + blobURI: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg', encryptionKey: 'someKey', dimensions: { height: 1010, width: 576 }, thumbHash: 'thumb', }, ], localID: 'local0', }; expect(translateClientDBMediaInfosToMedia(clientDBMessageInfo)).toStrictEqual( rawMessageInfo.media, ); }); test('Test translateClientDBMediaInfosToMedia() with encrypted video', () => { const clientDBMessageInfo: ClientDBMessageInfo = { id: 'local0', local_id: 'local0', thread: '90145', user: '90134', type: '15', future_type: null, time: '1665014145088', content: '[{"type":"video","uploadID":"localUpload0","thumbnailUploadID":"localUpload1"}]', media_infos: [ { id: 'localUpload0', uri: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', type: 'video', extras: '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someVideoKey"}', }, { id: 'localUpload1', uri: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg', type: 'photo', extras: '{"dimensions":{"height":1010,"width":576},"loop":false,"encryption_key":"someThumbKey","thumb_hash":"thumb"}', }, ], }; const rawMessageInfo = { type: 15, threadID: '90145', creatorID: '90134', time: 1665014145088, media: [ { id: 'localUpload0', type: 'encrypted_video', - holder: + blobURI: 'assets-library://asset/asset.mov?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=mov', encryptionKey: 'someVideoKey', dimensions: { height: 1010, width: 576 }, loop: false, thumbnailID: 'localUpload1', - thumbnailHolder: + thumbnailBlobURI: 'assets-library://asset/asset.jpeg?id=6F1BEA56-3875-474C-B3AF-B11DEDCBAFF2&ext=jpeg', thumbnailEncryptionKey: 'someThumbKey', thumbnailThumbHash: 'thumb', }, ], localID: 'local0', }; expect(translateClientDBMediaInfosToMedia(clientDBMessageInfo)).toStrictEqual( rawMessageInfo.media, ); }); diff --git a/native/media/multimedia.react.js b/native/media/multimedia.react.js index 5cafb436f..ae24923fc 100644 --- a/native/media/multimedia.react.js +++ b/native/media/multimedia.react.js @@ -1,223 +1,231 @@ // @flow import { Image } from 'expo-image'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; +import { + encryptedMediaBlobURI, + encryptedVideoThumbnailBlobURI, +} from 'lib/media/media-utils.js'; import type { MediaInfo, AvatarMediaInfo } from 'lib/types/media-types.js'; import EncryptedImage from './encrypted-image.react.js'; import RemoteImage from './remote-image.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; type Source = | { +kind: 'uri', +uri: string, +thumbHash?: ?string, } | { +kind: 'encrypted', +blobURI: string, +encryptionKey: string, +thumbHash?: ?string, }; type BaseProps = { +mediaInfo: MediaInfo | AvatarMediaInfo, +spinnerColor: string, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; type State = { +currentSource: Source, +departingSource: ?Source, }; class Multimedia extends React.PureComponent { static defaultProps = { spinnerColor: 'black', }; constructor(props: Props) { super(props); this.state = { currentSource: Multimedia.sourceFromMediaInfo(props.mediaInfo), departingSource: null, }; } get inputState() { const { inputState } = this.props; invariant(inputState, 'inputState should be set in Multimedia'); return inputState; } componentDidMount() { this.reportSourceDisplayed(this.state.currentSource, true); } componentWillUnmount() { const { currentSource, departingSource } = this.state; this.reportSourceDisplayed(currentSource, false); if (departingSource) { this.reportSourceDisplayed(departingSource, false); } } componentDidUpdate(prevProps: Props, prevState: State) { const newSource = Multimedia.sourceFromMediaInfo(this.props.mediaInfo); const oldSource = this.state.currentSource; if (!_isEqual(newSource)(oldSource)) { this.reportSourceDisplayed(newSource, true); const { departingSource } = this.state; if (departingSource && !_isEqual(oldSource)(departingSource)) { // If there's currently an existing departingSource, that means that // oldSource hasn't loaded yet. Since it's being replaced anyways // we don't need to display it anymore, so we can unlink it now this.reportSourceDisplayed(oldSource, false); this.setState({ currentSource: newSource }); } else { this.setState({ currentSource: newSource, departingSource: oldSource }); } } const newDepartingSource = this.state.departingSource; const oldDepartingSource = prevState.departingSource; if ( oldDepartingSource && !_isEqual(oldDepartingSource)(newDepartingSource) ) { this.reportSourceDisplayed(oldDepartingSource, false); } } render() { const images = []; const { currentSource, departingSource } = this.state; if (departingSource) { images.push(this.renderSource(currentSource, true)); images.push(this.renderSource(departingSource, false, false)); } else { images.push(this.renderSource(currentSource)); } return {images}; } renderSource( source: Source, invisibleLoad?: boolean = false, triggerOnLoad?: boolean = true, ) { const onLoadProp = triggerOnLoad ? this.onLoad : undefined; if (source.kind === 'encrypted') { return ( ); } const { uri, thumbHash } = source; const placeholder = thumbHash ? { thumbhash: thumbHash } : null; if (uri.startsWith('http')) { return ( ); } else { return ( ); } } onLoad = () => { this.setState({ departingSource: null }); }; reportSourceDisplayed = (source: Source, isLoaded: boolean) => { if (source.kind === 'uri') { this.inputState.reportURIDisplayed(source.uri, isLoaded); } }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return static sourceFromMediaInfo(mediaInfo: MediaInfo | AvatarMediaInfo): Source { if (mediaInfo.type === 'photo') { return { kind: 'uri', uri: mediaInfo.uri, thumbHash: mediaInfo.thumbHash, }; } else if (mediaInfo.type === 'video') { return { kind: 'uri', uri: mediaInfo.thumbnailURI, thumbHash: mediaInfo.thumbnailThumbHash, }; } else if (mediaInfo.type === 'encrypted_photo') { + // destructuring needed for Flow + const { index, ...media } = mediaInfo; return { kind: 'encrypted', - blobURI: mediaInfo.holder, + blobURI: encryptedMediaBlobURI(media), encryptionKey: mediaInfo.encryptionKey, thumbHash: mediaInfo.thumbHash, }; } else if (mediaInfo.type === 'encrypted_video') { + // destructuring needed for Flow + const { index, ...media } = mediaInfo; return { kind: 'encrypted', - blobURI: mediaInfo.thumbnailHolder, + blobURI: encryptedVideoThumbnailBlobURI(media), encryptionKey: mediaInfo.thumbnailEncryptionKey, thumbHash: mediaInfo.thumbnailThumbHash, }; } else { invariant(false, 'Invalid mediaInfo type'); } } } const styles = StyleSheet.create({ container: { flex: 1, }, image: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const ConnectedMultimedia: React.ComponentType = React.memo(function ConnectedMultimedia(props: BaseProps) { const inputState = React.useContext(InputStateContext); return ; }); export default ConnectedMultimedia; diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js index afc254eff..6bce8ad91 100644 --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -1,107 +1,110 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { + encryptedMediaBlobURI, + encryptedVideoThumbnailBlobURI, +} from 'lib/media/media-utils.js'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react.js'; import sendFailed from './multimedia-message-send-failed.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import Multimedia from '../media/multimedia.react.js'; type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; class MultimediaMessage extends React.PureComponent { render() { const { item, inputState } = this.props; invariant( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA, 'MultimediaMessage should only be used for multimedia messages', ); const { localID, media } = item.messageInfo; invariant(inputState, 'inputState should be set in MultimediaMessage'); const pendingUploads = localID ? inputState.assignedUploads[localID] : null; const multimedia = []; for (const singleMedia of media) { const pendingUpload = pendingUploads ? pendingUploads.find(upload => upload.localID === singleMedia.id) : null; const thumbHash = singleMedia.thumbHash ?? singleMedia.thumbnailThumbHash; let mediaSource; if (singleMedia.type === 'photo' || singleMedia.type === 'video') { const { type, uri, thumbnailURI, dimensions } = singleMedia; mediaSource = { type, uri, thumbHash, thumbnailURI, dimensions }; } else { - const { - type, - holder: blobURI, - encryptionKey, - thumbnailHolder: thumbnailBlobURI, - thumbnailEncryptionKey, - dimensions, - } = singleMedia; + const { type, encryptionKey, thumbnailEncryptionKey, dimensions } = + singleMedia; + const blobURI = encryptedMediaBlobURI(singleMedia); + const thumbnailBlobURI = + singleMedia.type === 'encrypted_video' + ? encryptedVideoThumbnailBlobURI(singleMedia) + : null; mediaSource = { type, blobURI, encryptionKey, thumbnailBlobURI, thumbnailEncryptionKey, dimensions, thumbHash, }; } multimedia.push( , ); } invariant(multimedia.length > 0, 'should be at least one multimedia...'); const content = multimedia.length > 1 ? (
{multimedia}
) : ( multimedia ); return ( 1} borderRadius={16} > {content} ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo(function ConnectedMultimediaMessage(props) { const inputState = React.useContext(InputStateContext); return ; }); export default ConnectedMultimediaMessage; diff --git a/web/modals/threads/gallery/thread-settings-media-gallery.react.js b/web/modals/threads/gallery/thread-settings-media-gallery.react.js index 079208642..bf74758b9 100644 --- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js +++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js @@ -1,188 +1,191 @@ // @flow import * as React from 'react'; import { fetchThreadMedia } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; +import { + encryptedMediaBlobURI, + encryptedVideoThumbnailBlobURI, +} from 'lib/media/media-utils.js'; import type { Media } from 'lib/types/media-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import GalleryItem from './thread-settings-media-gallery-item.react.js'; import css from './thread-settings-media-gallery.css'; import Tabs from '../../../components/tabs.react.js'; import MultimediaModal from '../../../media/multimedia-modal.react.js'; import Modal from '../../modal.react.js'; type MediaGalleryTab = 'All' | 'Images' | 'Videos'; type ThreadSettingsMediaGalleryModalProps = { +onClose: () => void, +parentThreadInfo: ThreadInfo, +limit: number, +activeTab: MediaGalleryTab, }; function ThreadSettingsMediaGalleryModal( props: ThreadSettingsMediaGalleryModalProps, ): React.Node { const { pushModal } = useModalContext(); const { onClose, parentThreadInfo, limit, activeTab } = props; const { id: threadID } = parentThreadInfo; const modalName = 'Media'; const callFetchThreadMedia = useServerCall(fetchThreadMedia); const [mediaInfos, setMediaInfos] = React.useState([]); const [tab, setTab] = React.useState(activeTab); React.useEffect(() => { const fetchData = async () => { const result = await callFetchThreadMedia({ threadID, limit, offset: 0, }); setMediaInfos(result.media); }; fetchData(); }, [callFetchThreadMedia, threadID, limit]); const onClick = React.useCallback( (media: Media) => { const thumbHash = media.thumbnailThumbHash ?? media.thumbHash; let mediaInfo = { thumbHash, dimensions: media.dimensions, }; if (media.type === 'photo' || media.type === 'video') { const { uri, thumbnailURI } = media; mediaInfo = { ...mediaInfo, type: media.type, uri, thumbnailURI, }; } else { - const { - holder: blobURI, - encryptionKey, - thumbnailHolder: thumbnailBlobURI, - thumbnailEncryptionKey, - } = media; + const { encryptionKey, thumbnailEncryptionKey } = media; + const thumbnailBlobURI = + media.type === 'encrypted_video' + ? encryptedVideoThumbnailBlobURI(media) + : null; mediaInfo = { ...mediaInfo, type: media.type, - blobURI, + blobURI: encryptedMediaBlobURI(media), encryptionKey, thumbnailBlobURI, thumbnailEncryptionKey, }; } pushModal(); }, [pushModal], ); const mediaGalleryItems = React.useMemo(() => { let filteredMediaInfos = mediaInfos; if (tab === 'Images') { filteredMediaInfos = mediaInfos.filter( mediaInfo => mediaInfo.type === 'photo' || mediaInfo.type === 'encrypted_photo', ); } else if (tab === 'Videos') { filteredMediaInfos = mediaInfos.filter( mediaInfo => mediaInfo.type === 'video' || mediaInfo.type === 'encrypted_video', ); } return filteredMediaInfos.map((media, i) => { let imageSource; if (media.type === 'photo') { imageSource = { kind: 'plain', uri: media.uri, thumbHash: media.thumbHash, }; } else if (media.type === 'video') { imageSource = { kind: 'plain', uri: media.thumbnailURI, thumbHash: media.thumbnailThumbHash, }; } else if (media.type === 'encrypted_photo') { imageSource = { kind: 'encrypted', - blobURI: media.holder, + blobURI: encryptedMediaBlobURI(media), encryptionKey: media.encryptionKey, thumbHash: media.thumbHash, }; } else { imageSource = { kind: 'encrypted', - blobURI: media.thumbnailHolder, + blobURI: encryptedVideoThumbnailBlobURI(media), encryptionKey: media.thumbnailEncryptionKey, thumbHash: media.thumbnailThumbHash, }; } return ( onClick(media)} imageSource={imageSource} imageCSSClass={css.media} imageContainerCSSClass={css.mediaContainer} /> ); }); }, [tab, mediaInfos, onClick]); const handleScroll = React.useCallback( async event => { const container = event.target; // Load more data when the user is within 1000 pixels of the end const buffer = 1000; if ( container.scrollHeight - container.scrollTop > container.clientHeight + buffer ) { return; } const result = await callFetchThreadMedia({ threadID, limit, offset: mediaInfos.length, }); setMediaInfos([...mediaInfos, ...result.media]); }, [callFetchThreadMedia, threadID, limit, mediaInfos], ); return (
{mediaGalleryItems}
{mediaGalleryItems}
{mediaGalleryItems}
); } export default ThreadSettingsMediaGalleryModal;