// Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction } from 'redux-thunk'; import type { AttachmentType } from '../../types/Attachment'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { ConversationUnloadedActionType, MessageChangedActionType, MessageDeletedActionType, MessageExpiredActionType, } from './conversations'; import type { MIMEType } from '../../types/MIME'; import type { MediaItemType } from '../../types/MediaItem'; import type { StateType as RootStateType } from '../reducer'; import dataInterface from '../../sql/Client'; import { CONVERSATION_UNLOADED, MESSAGE_CHANGED, MESSAGE_DELETED, MESSAGE_EXPIRED, } from './conversations'; import { VERSION_NEEDED_FOR_DISPLAY } from '../../types/Message2'; import { isDownloading, hasFailed } from '../../types/Attachment'; import { isNotNil } from '../../util/isNotNil'; import { useBoundActions } from '../../hooks/useBoundActions'; // eslint-disable-next-line local-rules/type-alias-readonlydeep type MediaType = { path: string; objectURL: string; thumbnailObjectUrl?: string; contentType: MIMEType; index: number; attachment: AttachmentType; message: { attachments: Array; conversationId: string; id: string; received_at: number; received_at_ms: number; sent_at: number; }; }; // eslint-disable-next-line local-rules/type-alias-readonlydeep export type MediaGalleryStateType = { documents: Array; media: Array; }; const LOAD_MEDIA_ITEMS = 'mediaGallery/LOAD_MEDIA_ITEMS'; // eslint-disable-next-line local-rules/type-alias-readonlydeep type LoadMediaItemslActionType = { type: typeof LOAD_MEDIA_ITEMS; payload: { documents: Array; media: Array; }; }; // eslint-disable-next-line local-rules/type-alias-readonlydeep type MediaGalleryActionType = | ConversationUnloadedActionType | LoadMediaItemslActionType | MessageChangedActionType | MessageDeletedActionType | MessageExpiredActionType; function loadMediaItems( conversationId: string ): ThunkAction { return async dispatch => { const { getAbsoluteAttachmentPath, upgradeMessageSchema } = window.Signal.Migrations; // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: const DEFAULT_MEDIA_FETCH_COUNT = 50; const DEFAULT_DOCUMENTS_FETCH_COUNT = 150; const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); const rawMedia = await dataInterface.getMessagesWithVisualMediaAttachments( conversationId, { limit: DEFAULT_MEDIA_FETCH_COUNT, } ); const rawDocuments = await dataInterface.getMessagesWithFileAttachments( conversationId, { limit: DEFAULT_DOCUMENTS_FETCH_COUNT, } ); // First we upgrade these messages to ensure that they have thumbnails await Promise.all( rawMedia.map(async message => { const { schemaVersion } = message; const model = window.MessageController.register(message.id, message); if (schemaVersion && schemaVersion < VERSION_NEEDED_FOR_DISPLAY) { const upgradedMsgAttributes = await upgradeMessageSchema(message); model.set(upgradedMsgAttributes); await dataInterface.saveMessage(upgradedMsgAttributes, { ourUuid }); } }) ); const media: Array = rawMedia .flatMap(message => { return (message.attachments || []).map( ( attachment: AttachmentType, index: number ): MediaType | undefined => { if ( !attachment.path || !attachment.thumbnail || isDownloading(attachment) || hasFailed(attachment) ) { return; } const { thumbnail } = attachment; return { path: attachment.path, objectURL: getAbsoluteAttachmentPath(attachment.path), thumbnailObjectUrl: thumbnail?.path ? getAbsoluteAttachmentPath(thumbnail.path) : undefined, contentType: attachment.contentType, index, attachment, message: { attachments: message.attachments || [], conversationId: window.ConversationController.lookupOrCreate({ uuid: message.sourceUuid, e164: message.source, reason: 'conversation_view.showAllMedia', })?.id || message.conversationId, id: message.id, received_at: message.received_at, received_at_ms: Number(message.received_at_ms), sent_at: message.sent_at, }, }; } ); }) .filter(isNotNil); // Unlike visual media, only one non-image attachment is supported const documents: Array = rawDocuments .map(message => { const attachments = message.attachments || []; const attachment = attachments[0]; if (!attachment) { return; } return { contentType: attachment.contentType, index: 0, attachment, message: { ...message, attachments: [attachment], }, }; }) .filter(isNotNil); dispatch({ type: LOAD_MEDIA_ITEMS, payload: { documents, media, }, }); }; } export const actions = { loadMediaItems, }; export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); export function getEmptyState(): MediaGalleryStateType { return { documents: [], media: [], }; } export function reducer( state: Readonly = getEmptyState(), action: Readonly ): MediaGalleryStateType { if (action.type === LOAD_MEDIA_ITEMS) { return { ...state, ...action.payload, }; } if (action.type === MESSAGE_CHANGED) { if (!action.payload.data.deletedForEveryone) { return state; } return { ...state, media: state.media.filter(item => item.message.id !== action.payload.id), documents: state.documents.filter( item => item.message.id !== action.payload.id ), }; } if (action.type === MESSAGE_DELETED || action.type === MESSAGE_EXPIRED) { return { ...state, media: state.media.filter(item => item.message.id !== action.payload.id), documents: state.documents.filter( item => item.message.id !== action.payload.id ), }; } if (action.type === CONVERSATION_UNLOADED) { return getEmptyState(); } return state; }