diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f4cdf00c365..76191f7ae78 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5918,11 +5918,15 @@ }, "icu:ConversationDetailsMediaList--shared-media": { "messageformat": "Shared media", - "description": "Title for the media thumbnails in the conversation details screen" + "description": "(Deleted 2025/07/01) Title for the media thumbnails in the conversation details screen" }, "icu:ConversationDetailsMediaList--show-all": { "messageformat": "See all", - "description": "This is a button on the conversation details to show all media" + "description": "(Deleted 2025/07/01) This is a button on the conversation details to show all media" + }, + "icu:ConversationDetailsMediaList--title": { + "messageformat": "Media, links, and files", + "description": "Title for the show all media button in the conversation details screen" }, "icu:ConversationDetailsMembershipList--title": { "messageformat": "{number, plural, one {# member} other {# members}}", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 547bf9e442f..9093b27ea88 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2479,6 +2479,7 @@ button.ConversationDetails__action-button { display: flex; flex-grow: 1; overflow-y: auto; + overflow-x: hidden; padding: 20px; } @@ -2496,161 +2497,13 @@ button.ConversationDetails__action-button { } .module-media-gallery__sections { + min-width: 0; + display: flex; flex-grow: 1; flex-direction: column; } -// Module: Attachment Section - -.module-attachment-section { - width: 100%; -} - -.module-attachment-section__header { - @include mixins.font-body-1-bold; -} - -.module-attachment-section__items { - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: flex-start; - align-items: flex-start; -} - -// Module: Document List Item - -.module-document-list-item { - width: 100%; - height: 72px; -} - -.module-document-list-item--with-separator { - @include mixins.light-theme { - border-bottom: 1px solid variables.$color-gray-02; - } - @include mixins.dark-theme { - border-bottom: 1px solid variables.$color-gray-75; - } -} - -.module-document-list-item__content { - @include mixins.button-reset; - & { - width: 100%; - height: 100%; - - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - } - - @include mixins.keyboard-mode { - &:focus { - box-shadow: 0px 0px 0px 2px variables.$color-ultramarine; - } - } -} - -.module-document-list-item__icon { - flex-shrink: 0; - - width: 48px; - height: 48px; - @include mixins.color-svg( - '../images/generic-file.svg', - variables.$color-gray-45 - ); -} - -.module-document-list-item__metadata { - display: inline-flex; - flex-direction: column; - flex-grow: 1; - flex-shrink: 0; - margin-inline: 8px; -} - -.module-document-list-item__file-size { - display: inline-block; - margin-top: 8px; - @include mixins.font-body-2; -} - -.module-document-list-item__date { - display: inline-block; - flex-shrink: 0; -} - -// Module: Media Grid Item - -.module-media-grid-item { - @include mixins.button-reset; - & { - height: 94px; - width: 94px; - background-color: variables.$color-gray-05; - margin-inline-end: 4px; - margin-bottom: 4px; - position: relative; - } - - @include mixins.keyboard-mode { - &:focus { - box-shadow: 0px 0px 0px 2px variables.$color-ultramarine; - } - } -} - -.module-media-grid-item__image { - height: 94px; - width: 100%; - object-fit: cover; -} - -.module-media-grid-item__icon { - position: absolute; - top: 15px; - bottom: 15px; - inset-inline: 15px; -} - -.module-media-grid-item__icon-image { - @include mixins.color-svg('../images/image.svg', variables.$color-gray-45); -} - -.module-media-grid-item__image-container { - position: relative; -} - -.module-media-grid-item__circle-overlay { - @include mixins.position-absolute-center; - width: 42px; - height: 42px; - background-color: variables.$color-white; - border-radius: 21px; -} - -.module-media-grid-item__play-overlay { - @include mixins.position-absolute-center; - height: 24px; - width: 24px; - @include mixins.color-svg( - '../images/icons/v3/play/play-fill.svg', - variables.$color-ultramarine - ); -} - -.module-media-grid-item__icon-video { - @include mixins.color-svg('../images/movie.svg', variables.$color-gray-45); -} - -.module-media-grid-item__icon-generic { - @include mixins.color-svg('../images/file.svg', variables.$color-gray-45); -} - /* Module: Empty State*/ .module-empty-state { diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 85db763fba3..81fffc8e0f0 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -131,6 +131,12 @@ } } + &--media { + &::after { + @include details-icon('../images/icons/v3/album/album-tilt.svg'); + } + } + &--mention { &::after { @include details-icon('../images/icons/v3/at/at.svg'); @@ -282,46 +288,6 @@ } } - &-media-list { - &__root { - display: flex; - justify-content: center; - padding-block: 0; - padding-inline: 20px; - padding-bottom: 24px; - - .module-media-grid-item { - border-radius: 4px; - height: auto; - margin-block: 0; - margin-inline: 4px; - max-height: 94px; - overflow: hidden; - width: calc(100% / 6); - - .module-media-grid-item__icon { - &::before { - content: ''; - display: block; - padding-top: 100%; - } - } - - .module-media-grid-item__image-container, - img { - margin: 0; - } - } - } - - &__show-all { - background: none; - border: none; - padding: 0; - color: light-dark(variables.$color-gray-95, variables.$color-gray-05); - } - } - &-panel-row { $row-root-selector: '#{&}__root'; &__root { diff --git a/ts/axo/AxoSymbol.tsx b/ts/axo/AxoSymbol.tsx index 99730db3c94..84a67c14150 100644 --- a/ts/axo/AxoSymbol.tsx +++ b/ts/axo/AxoSymbol.tsx @@ -246,7 +246,7 @@ export namespace AxoSymbol { */ export type IconProps = Readonly<{ - size: 14 | 16 | 20; + size: 14 | 16 | 20 | 24; symbol: AxoSymbolName; label: string | null; }>; diff --git a/ts/components/ImageOrBlurhash.tsx b/ts/components/ImageOrBlurhash.tsx index 9081e123180..f903f83ac41 100644 --- a/ts/components/ImageOrBlurhash.tsx +++ b/ts/components/ImageOrBlurhash.tsx @@ -67,7 +67,6 @@ export function ImageOrBlurhash({ backgroundPosition: 'center', }} loading={blurHashUrl != null ? 'lazy' : 'eager'} - decoding={blurHashUrl != null ? 'async' : 'auto'} /> ); } diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index 26d5351f343..44ede476d05 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -14,6 +14,7 @@ import { VIDEO_MP4, VIDEO_QUICKTIME, stringToMIMEType, + type MIMEType, } from '../types/MIME'; import { fakeAttachment } from '../test-helpers/fakeAttachment'; @@ -26,7 +27,11 @@ export default { args: {}, } satisfies Meta; -type OverridePropsMediaItemType = Partial & { caption?: string }; +type OverridePropsMediaItemType = Partial & { + caption?: string; + objectURL?: string; + contentType?: MIMEType; +}; function createMediaItem( overrideProps: OverridePropsMediaItemType @@ -34,21 +39,19 @@ function createMediaItem( return { attachment: fakeAttachment({ caption: overrideProps.caption || '', - contentType: IMAGE_JPEG, + contentType: overrideProps.contentType ?? IMAGE_JPEG, fileName: overrideProps.objectURL, url: overrideProps.objectURL, }), - contentType: IMAGE_JPEG, index: 0, message: { - attachments: [], conversationId: '1234', + type: 'incoming', id: 'image-msg', receivedAt: 0, receivedAtMs: Date.now(), sentAt: Date.now(), }, - objectURL: '', ...overrideProps, }; } @@ -88,17 +91,15 @@ export function Multimedia(): JSX.Element { caption: 'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.', }), - contentType: IMAGE_JPEG, index: 0, message: { - attachments: [], conversationId: '1234', + type: 'incoming', id: 'image-msg', receivedAt: 1, receivedAtMs: Date.now(), sentAt: Date.now(), }, - objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg', }, { attachment: fakeAttachment({ @@ -106,28 +107,24 @@ export function Multimedia(): JSX.Element { fileName: 'pixabay-Soap-Bubble-7141.mp4', url: '/fixtures/pixabay-Soap-Bubble-7141.mp4', }), - contentType: VIDEO_MP4, index: 1, message: { - attachments: [], conversationId: '1234', + type: 'incoming', id: 'video-msg', receivedAt: 2, receivedAtMs: Date.now(), sentAt: Date.now(), }, - objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4', }, createMediaItem({ contentType: IMAGE_JPEG, index: 2, - thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg', objectURL: '/fixtures/kitten-1-64-64.jpg', }), createMediaItem({ contentType: IMAGE_JPEG, index: 3, - thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg', objectURL: '/fixtures/kitten-2-64-64.jpg', }), ], @@ -145,17 +142,15 @@ export function MissingMedia(): JSX.Element { fileName: 'tina-rolf-269345-unsplash.jpg', url: '/fixtures/tina-rolf-269345-unsplash.jpg', }), - contentType: IMAGE_JPEG, index: 0, message: { - attachments: [], conversationId: '1234', + type: 'incoming', id: 'image-msg', receivedAt: 3, receivedAtMs: Date.now(), sentAt: Date.now(), }, - objectURL: undefined, }, ], }); diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index fc19a57f5ec..0c6c26e0454 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -140,13 +140,10 @@ export function Lightbox({ >(); const currentItem = media[selectedIndex]; - const { - attachment, - contentType, - loop = false, - objectURL, - incrementalObjectUrl, - } = currentItem || {}; + const attachment = currentItem?.attachment; + const url = attachment?.url; + const incrementalUrl = attachment?.incrementalUrl; + const contentType = attachment?.contentType; const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined); const isDownloading = @@ -599,7 +596,7 @@ export function Lightbox({ !isVideoTypeSupported && isVideo(contentType); if (isImageTypeSupported) { - if (objectURL) { + if (url) { content = (
@@ -642,19 +639,19 @@ export function Lightbox({ ); } } else if (isVideoTypeSupported) { - const shouldLoop = loop || isAttachmentGIF || isViewOnce; + const shouldLoop = isAttachmentGIF || isViewOnce; content = ( ); } else if (isUnsupportedImageType || isUnsupportedVideoType) { @@ -834,7 +831,7 @@ export function Lightbox({ 'Lightbox__thumbnail--selected': index === selectedIndex, })} - key={item.thumbnailObjectUrl} + key={item.attachment.thumbnail?.url} type="button" onClick={( event: React.MouseEvent< @@ -848,10 +845,10 @@ export function Lightbox({ onSelectAttachment(index); }} > - {item.thumbnailObjectUrl ? ( + {item.attachment.thumbnail?.url ? ( {i18n('icu:lightboxImageAlt')} ) : (
diff --git a/ts/components/conversation/AttachmentStatusIcon.stories.tsx b/ts/components/conversation/AttachmentStatusIcon.stories.tsx index bbc32507d21..40895aeb029 100644 --- a/ts/components/conversation/AttachmentStatusIcon.stories.tsx +++ b/ts/components/conversation/AttachmentStatusIcon.stories.tsx @@ -15,16 +15,13 @@ export default { args: { attachment: fakeAttachment(), isIncoming: false, - renderAttachmentDownloaded: () => { - return
🔥🔥
; - }, }, } satisfies Meta; export function Default(args: PropsType): JSX.Element { return (
- + 🔥🔥
); } @@ -32,7 +29,9 @@ export function Default(args: PropsType): JSX.Element { export function NoAttachment(args: PropsType): JSX.Element { return (
- + + 🔥🔥 +
); } @@ -43,7 +42,9 @@ export function NeedsDownload(args: PropsType): JSX.Element { + > + 🔥🔥 +
); } @@ -59,7 +60,9 @@ export function Downloading(args: PropsType): JSX.Element { size: 1000000, totalDownloaded: 750000, })} - /> + > + 🔥🔥 +
); } @@ -106,7 +109,9 @@ export function Interactive(args: PropsType): JSX.Element { - + + 🔥🔥 + ); } diff --git a/ts/components/conversation/AttachmentStatusIcon.tsx b/ts/components/conversation/AttachmentStatusIcon.tsx index 3bb60c40ad6..0d637c3a122 100644 --- a/ts/components/conversation/AttachmentStatusIcon.tsx +++ b/ts/components/conversation/AttachmentStatusIcon.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, type ReactNode } from 'react'; import classNames from 'classnames'; import { SpinnerV2 } from '../SpinnerV2'; @@ -13,9 +13,9 @@ const TRANSITION_DELAY = 200; export type PropsType = { attachment: AttachmentForUIType | undefined; - isAttachmentNotAvailable: boolean; + isExpired?: boolean; isIncoming: boolean; - renderAttachmentDownloaded: () => JSX.Element; + children?: ReactNode; }; enum IconState { @@ -26,12 +26,18 @@ enum IconState { export function AttachmentStatusIcon({ attachment, - isAttachmentNotAvailable, + isExpired, isIncoming, - renderAttachmentDownloaded, + children, }: PropsType): JSX.Element | null { const [isWaiting, setIsWaiting] = useState(false); + const isAttachmentNotAvailable = + isExpired || + (attachment != null && + attachment.isPermanentlyUndownloadable && + !attachment.wasTooBig); + let state: IconState = IconState.Downloaded; if (attachment && isAttachmentNotAvailable) { state = IconState.Downloaded; @@ -159,9 +165,5 @@ export function AttachmentStatusIcon({ ); } - return ( -
- {renderAttachmentDownloaded()} -
- ); + return
{children}
; } diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index a5366793f15..2b93e1d73a6 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1367,10 +1367,6 @@ export class Message extends React.PureComponent { const { fileName, size, contentType } = firstAttachment; const isIncoming = direction === 'incoming'; - const renderAttachmentDownloaded = () => { - return ; - }; - const willShowMetadata = expirationLength || expirationTimestamp || !shouldHideMetadata; @@ -1415,10 +1411,10 @@ export class Message extends React.PureComponent { + > + +
{ this.renderTapToViewIcon()} - /> + > + {this.renderTapToViewIcon()} + {content}
); diff --git a/ts/components/conversation/contactUtil.tsx b/ts/components/conversation/contactUtil.tsx index c57a6f55851..e50464a13c3 100644 --- a/ts/components/conversation/contactUtil.tsx +++ b/ts/components/conversation/contactUtil.tsx @@ -28,31 +28,24 @@ export function renderAvatar({ const avatarUrl = avatar && avatar.avatar && avatar.avatar.path; const title = getName(contact) || ''; - const isAttachmentNotAvailable = Boolean( - avatar?.avatar?.isPermanentlyUndownloadable - ); - - const renderAttachmentDownloaded = () => ( - - ); return ( + > + + ); } diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 4fd137a3999..bf4a20e3eb2 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -65,7 +65,7 @@ const createProps = ( isGroup: true, isSignalConversation: false, leaveGroup: action('leaveGroup'), - loadRecentMediaItems: action('loadRecentMediaItems'), + hasMedia: true, memberships: times(32, i => ({ isAdmin: i === 1, member: getDefaultConversation({ @@ -86,7 +86,6 @@ const createProps = ( showContactModal: action('showContactModal'), pushPanelForConversation: action('pushPanelForConversation'), showConversation: action('showConversation'), - showLightbox: action('showLightbox'), startAvatarDownload: action('startAvatarDownload'), updateGroupAttributes: async () => { action('updateGroupAttributes')(); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 3792bb87927..872fa059e9c 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -29,8 +29,6 @@ import { AddGroupMembersModal } from './AddGroupMembersModal'; import { ConversationDetailsActions } from './ConversationDetailsActions'; import { ConversationDetailsHeader } from './ConversationDetailsHeader'; import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon'; -import type { Props as ConversationDetailsMediaListPropsType } from './ConversationDetailsMediaList'; -import { ConversationDetailsMediaList } from './ConversationDetailsMediaList'; import type { GroupV2Membership } from './ConversationDetailsMembershipList'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList'; import type { @@ -82,6 +80,7 @@ export type StateProps = { canAddNewMembers: boolean; conversation?: ConversationType; hasGroupLink: boolean; + hasMedia: boolean; getPreferredBadge: PreferredBadgeSelectorType; hasActiveCall: boolean; i18n: LocalizerType; @@ -122,7 +121,6 @@ type ActionProps = { deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; getProfilesForConversation: (id: string) => unknown; leaveGroup: (conversationId: string) => void; - loadRecentMediaItems: (id: string, limit: number) => void; onDeleteNicknameAndNote: () => void; onOpenEditNicknameAndNoteModal: () => void; onOutgoingAudioCallInConversation: (conversationId: string) => unknown; @@ -150,7 +148,7 @@ type ActionProps = { onFailure?: () => unknown; } ) => unknown; -} & Pick; +}; export type Props = StateProps & ActionProps; @@ -180,6 +178,7 @@ export function ConversationDetails({ conversation, deleteAvatarFromDisk, hasGroupLink, + hasMedia, getPreferredBadge, getProfilesForConversation, groupsInCommon, @@ -189,7 +188,6 @@ export function ConversationDetails({ isGroup, isSignalConversation, leaveGroup, - loadRecentMediaItems, memberships, maxGroupSize, maxRecommendedGroupSize, @@ -211,7 +209,6 @@ export function ConversationDetails({ setMuteExpiration, showContactModal, showConversation, - showLightbox, startAvatarDownload, theme, toggleAboutContactModal, @@ -691,6 +688,22 @@ export function ConversationDetails({ } /> )} + {hasMedia && ( + + } + label={i18n('icu:ConversationDetailsMediaList--title')} + onClick={() => { + pushPanelForConversation({ + type: PanelType.AllMedia, + }); + }} + /> + )} {!isGroup && !conversation.isMe && ( toggleSafetyNumberModal(conversation.id)} @@ -779,18 +792,6 @@ export function ConversationDetails({ )} - - pushPanelForConversation({ - type: PanelType.AllMedia, - }) - } - showLightbox={showLightbox} - /> - {!isGroup && !conversation.isMe && !isSignalConversation && ( ; - -const createProps = (mediaItems?: Array): Props => ({ - conversation: getDefaultConversation({ - recentMediaItems: mediaItems || [], - }), - i18n, - loadRecentMediaItems: action('loadRecentMediaItems'), - showAllMedia: action('showAllMedia'), - showLightbox: action('showLightbox'), -}); - -export function Basic(): JSX.Element { - const mediaItems = createPreparedMediaItems(createRandomMedia); - const props = createProps(mediaItems); - - return ; -} diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx deleted file mode 100644 index 2ef3d51f3dd..00000000000 --- a/ts/components/conversation/conversation-details/ConversationDetailsMediaList.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -import type { LocalizerType } from '../../../types/Util'; -import type { ConversationType } from '../../../state/ducks/conversations'; -import type { AttachmentType } from '../../../types/Attachment'; - -import { PanelSection } from './PanelSection'; -import { bemGenerator } from './util'; -import { MediaGridItem } from '../media-gallery/MediaGridItem'; - -export type Props = { - conversation: ConversationType; - i18n: LocalizerType; - loadRecentMediaItems: (id: string, limit: number) => void; - showAllMedia: () => void; - showLightbox: (options: { - attachment: AttachmentType; - messageId: string; - }) => void; -}; - -const MEDIA_ITEM_LIMIT = 6; - -const bem = bemGenerator('ConversationDetails-media-list'); - -export function ConversationDetailsMediaList({ - conversation, - i18n, - loadRecentMediaItems, - showAllMedia, - showLightbox, -}: Props): JSX.Element | null { - const mediaItems = conversation.recentMediaItems || []; - - const mediaItemsLength = mediaItems.length; - - React.useEffect(() => { - loadRecentMediaItems(conversation.id, MEDIA_ITEM_LIMIT); - }, [conversation.id, loadRecentMediaItems, mediaItemsLength]); - - if (mediaItemsLength === 0) { - return null; - } - - return ( - - {i18n('icu:ConversationDetailsMediaList--show-all')} - - } - title={i18n('icu:ConversationDetailsMediaList--shared-media')} - > -
- {mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => ( - - showLightbox({ - attachment: mediaItem.attachment, - messageId: mediaItem.message.id, - }) - } - /> - ))} -
-
- ); -} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index 519a5140db1..141703cc840 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -4,11 +4,12 @@ import React from 'react'; import type { ItemClickEvent } from './types/ItemClickEvent'; -import type { LocalizerType } from '../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; import { DocumentListItem } from './DocumentListItem'; import { MediaGridItem } from './MediaGridItem'; import { missingCaseError } from '../../../util/missingCaseError'; +import { tw } from '../../../axo/tw'; export type Props = { header?: string; @@ -16,6 +17,7 @@ export type Props = { mediaItems: ReadonlyArray; onItemClick: (event: ItemClickEvent) => unknown; type: 'media' | 'documents'; + theme?: ThemeType; }; export function AttachmentSection({ @@ -24,45 +26,66 @@ export function AttachmentSection({ type, mediaItems, onItemClick, + theme, }: Props): JSX.Element { - return ( -
-

{header}

-
- {mediaItems.map((mediaItem, position, array) => { - const shouldShowSeparator = position < array.length - 1; - const { message, index, attachment } = mediaItem; + switch (type) { + case 'media': + return ( +
+

{header}

+
+ {mediaItems.map(mediaItem => { + const { message, index, attachment } = mediaItem; - const onClick = () => { - onItemClick({ type, message, attachment }); - }; + const onClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + onItemClick({ type, message, attachment }); + }; - switch (type) { - case 'media': return ( ); - case 'documents': + })} +
+
+ ); + case 'documents': + return ( +
+

{header}

+
+ {mediaItems.map(mediaItem => { + const { message, index, attachment } = mediaItem; + + const onClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + onItemClick({ type, message, attachment }); + }; + return ( ); - default: - throw missingCaseError(type); - } - })} -
-
- ); + })} +
+ + ); + default: + throw missingCaseError(type); + } } diff --git a/ts/components/conversation/media-gallery/DocumentListItem.stories.tsx b/ts/components/conversation/media-gallery/DocumentListItem.stories.tsx index 46646c78075..5cf17acbf26 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.stories.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.stories.tsx @@ -6,55 +6,22 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { Props } from './DocumentListItem'; import { DocumentListItem } from './DocumentListItem'; +import { createPreparedMediaItems, createRandomDocuments } from './utils/mocks'; export default { title: 'Components/Conversation/MediaGallery/DocumentListItem', - argTypes: { - timestamp: { control: { type: 'date' } }, - fileName: { control: { type: 'text' } }, - fileSize: { control: { type: 'number' } }, - shouldShowSeparator: { control: { type: 'boolean' } }, - }, - args: { - timestamp: Date.now(), - fileName: 'meow.jpg', - fileSize: 1024 * 1000 * 2, - shouldShowSeparator: false, - onClick: action('onClick'), - }, } satisfies Meta; -export function Single(args: Props): JSX.Element { - return ; -} - export function Multiple(): JSX.Element { - const items = [ - { - fileName: 'meow.jpg', - fileSize: 1024 * 1000 * 2, - timestamp: Date.now(), - }, - { - fileName: 'rickroll.mp4', - fileSize: 1024 * 1000 * 8, - timestamp: Date.now() - 24 * 60 * 60 * 1000, - }, - { - fileName: 'kitten.gif', - fileSize: 1024 * 1000 * 1.2, - timestamp: Date.now() - 14 * 24 * 60 * 60 * 1000, - shouldShowSeparator: false, - }, - ]; + const items = createPreparedMediaItems(createRandomDocuments); return ( <> - {items.map(item => ( + {items.map(mediaItem => ( ))} diff --git a/ts/components/conversation/media-gallery/DocumentListItem.tsx b/ts/components/conversation/media-gallery/DocumentListItem.tsx index 1cce4bef9b4..0071f80109c 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.tsx @@ -2,54 +2,46 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; -import classNames from 'classnames'; import moment from 'moment'; import { formatFileSize } from '../../../util/formatFileSize'; +import type { MediaItemType } from '../../../types/MediaItem'; +import { tw } from '../../../axo/tw'; +import { FileThumbnail } from '../../FileThumbnail'; export type Props = { // Required - timestamp: number; + mediaItem: MediaItemType; // Optional - fileName?: string; - fileSize?: number; - onClick?: () => void; - shouldShowSeparator?: boolean; + onClick?: (ev: React.MouseEvent) => void; }; -export function DocumentListItem({ - shouldShowSeparator = true, - fileName, - fileSize, - onClick, - timestamp, -}: Props): JSX.Element { +export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element { + const { attachment, message } = mediaItem; + + const { fileName, size: fileSize } = attachment; + + const timestamp = message.receivedAtMs || message.receivedAt; + return ( -
- -
+
+
+ {moment(timestamp).format('MMM D')} +
+ ); } diff --git a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx index 8d7d788bc85..43d9c7d58bb 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.stories.tsx @@ -34,10 +34,15 @@ const createProps = (overrideProps: Partial = {}): Props => ({ loadMoreMedia: action('loadMoreMedia'), saveAttachment: action('saveAttachment'), showLightbox: action('showLightbox'), + kickOffAttachmentDownload: action('kickOffAttachmentDownload'), + cancelAttachmentDownload: action('cancelAttachmentDownload'), }); export function Populated(): JSX.Element { - const documents = createRandomDocuments(Date.now(), days(1)).slice(0, 1); + const documents = createRandomDocuments(Date.now() - days(5), days(5)).slice( + 0, + 10 + ); const media = createPreparedMediaItems(createRandomMedia); const props = createProps({ documents, media }); diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index b3b449bc6cc..9d47571a25f 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react'; import moment from 'moment'; import type { ItemClickEvent } from './types/ItemClickEvent'; -import type { LocalizerType } from '../../../types/Util'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations'; import { AttachmentSection } from './AttachmentSection'; @@ -34,10 +34,13 @@ export type Props = { loadMoreDocuments: (id: string) => unknown; media: ReadonlyArray; saveAttachment: SaveAttachmentActionCreatorType; + kickOffAttachmentDownload: (options: { messageId: string }) => void; + cancelAttachmentDownload: (options: { messageId: string }) => void; showLightbox: (options: { attachment: AttachmentType; messageId: string; }) => void; + theme?: ThemeType; }; const MONTH_FORMAT = 'MMMM YYYY'; @@ -48,11 +51,22 @@ function MediaSection({ loading, media, saveAttachment, + kickOffAttachmentDownload, + cancelAttachmentDownload, showLightbox, type, + theme, }: Pick< Props, - 'documents' | 'i18n' | 'loading' | 'media' | 'saveAttachment' | 'showLightbox' + | 'documents' + | 'i18n' + | 'theme' + | 'loading' + | 'media' + | 'saveAttachment' + | 'kickOffAttachmentDownload' + | 'cancelAttachmentDownload' + | 'showLightbox' > & { type: 'media' | 'documents' }): JSX.Element { const mediaItems = type === 'media' ? media : documents; @@ -107,6 +121,7 @@ function MediaSection({ key={header} header={header} i18n={i18n} + theme={theme} type={type} mediaItems={section.mediaItems} onItemClick={(event: ItemClickEvent) => { @@ -117,10 +132,16 @@ function MediaSection({ } case 'media': { - showLightbox({ - attachment: event.attachment, - messageId: event.message.id, - }); + if (event.attachment.url || event.attachment.incrementalUrl) { + showLightbox({ + attachment: event.attachment, + messageId: event.message.id, + }); + } else if (event.attachment.pending) { + cancelAttachmentDownload({ messageId: event.message.id }); + } else { + kickOffAttachmentDownload({ messageId: event.message.id }); + } break; } @@ -147,6 +168,8 @@ export function MediaGallery({ loadMoreMedia, media, saveAttachment, + kickOffAttachmentDownload, + cancelAttachmentDownload, showLightbox, }: Props): JSX.Element { const focusRef = useRef(null); @@ -264,6 +287,8 @@ export function MediaGallery({ media={media} saveAttachment={saveAttachment} showLightbox={showLightbox} + kickOffAttachmentDownload={kickOffAttachmentDownload} + cancelAttachmentDownload={cancelAttachmentDownload} type="media" /> )} @@ -275,6 +300,8 @@ export function MediaGallery({ media={media} saveAttachment={saveAttachment} showLightbox={showLightbox} + kickOffAttachmentDownload={kickOffAttachmentDownload} + cancelAttachmentDownload={cancelAttachmentDownload} type="documents" /> )} diff --git a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx index 26d66a5d14e..f6f4fa0b712 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx @@ -4,9 +4,15 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; +import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext'; import type { MediaItemType } from '../../../types/MediaItem'; -import type { AttachmentType } from '../../../types/Attachment'; -import { stringToMIMEType } from '../../../types/MIME'; +import { SignalService } from '../../../protobuf'; +import { + IMAGE_JPEG, + VIDEO_MP4, + APPLICATION_OCTET_STREAM, + type MIMEType, +} from '../../../types/MIME'; import type { Props } from './MediaGridItem'; import { MediaGridItem } from './MediaGridItem'; @@ -18,21 +24,37 @@ export default { const createProps = ( overrideProps: Partial & { mediaItem: MediaItemType } -): Props => ({ - i18n, - mediaItem: overrideProps.mediaItem, - onClick: action('onClick'), -}); +): Props => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const theme = React.useContext(StorybookThemeContext); + + return { + i18n, + theme, + mediaItem: overrideProps.mediaItem, + onClick: action('onClick'), + }; +}; + +type OverridePropsMediaItemType = Partial & { + objectURL?: string; + contentType?: MIMEType; +}; const createMediaItem = ( - overrideProps: Partial = {} + overrideProps: OverridePropsMediaItemType ): MediaItemType => ({ - thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '', - contentType: overrideProps.contentType || stringToMIMEType(''), index: 0, - attachment: {} as AttachmentType, // attachment not useful in the component + attachment: overrideProps.attachment || { + path: '123', + contentType: overrideProps.contentType ?? IMAGE_JPEG, + size: 123, + url: overrideProps.objectURL, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + isPermanentlyUndownloadable: false, + }, message: { - attachments: [], + type: 'incoming', conversationId: '1234', id: 'id', receivedAt: Date.now(), @@ -43,8 +65,34 @@ const createMediaItem = ( export function Image(): JSX.Element { const mediaItem = createMediaItem({ - thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg', - contentType: stringToMIMEType('image/jpeg'), + objectURL: '/fixtures/kitten-1-64-64.jpg', + contentType: IMAGE_JPEG, + }); + + const props = createProps({ + mediaItem, + }); + + return ; +} + +export function WideImage(): JSX.Element { + const mediaItem = createMediaItem({ + objectURL: '/fixtures/wide.jpg', + contentType: IMAGE_JPEG, + }); + + const props = createProps({ + mediaItem, + }); + + return ; +} + +export function TallImage(): JSX.Element { + const mediaItem = createMediaItem({ + objectURL: '/fixtures/snow.jpg', + contentType: IMAGE_JPEG, }); const props = createProps({ @@ -56,8 +104,42 @@ export function Image(): JSX.Element { export function Video(): JSX.Element { const mediaItem = createMediaItem({ - thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg', - contentType: stringToMIMEType('video/mp4'), + attachment: { + incrementalUrl: 'abc', + screenshot: { + url: '/fixtures/kitten-2-64-64.jpg', + contentType: IMAGE_JPEG, + }, + contentType: VIDEO_MP4, + size: 1024, + isPermanentlyUndownloadable: false, + path: 'abcd', + }, + contentType: VIDEO_MP4, + }); + + const props = createProps({ + mediaItem, + }); + + return ; +} + +export function GIF(): JSX.Element { + const mediaItem = createMediaItem({ + attachment: { + url: 'abc', + screenshot: { + url: '/fixtures/kitten-2-64-64.jpg', + contentType: IMAGE_JPEG, + }, + contentType: VIDEO_MP4, + size: 1024, + isPermanentlyUndownloadable: false, + path: 'abcd', + flags: SignalService.AttachmentPointer.Flags.GIF, + }, + contentType: VIDEO_MP4, }); const props = createProps({ @@ -69,7 +151,53 @@ export function Video(): JSX.Element { export function MissingImage(): JSX.Element { const mediaItem = createMediaItem({ - contentType: stringToMIMEType('image/jpeg'), + contentType: IMAGE_JPEG, + attachment: { + contentType: IMAGE_JPEG, + size: 123, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + isPermanentlyUndownloadable: false, + }, + }); + + const props = createProps({ + mediaItem, + }); + + return ; +} + +export function PendingImage(): JSX.Element { + const mediaItem = createMediaItem({ + contentType: IMAGE_JPEG, + attachment: { + contentType: IMAGE_JPEG, + size: 123000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + isPermanentlyUndownloadable: false, + totalDownloaded: 0, + pending: true, + }, + }); + + const props = createProps({ + mediaItem, + }); + + return ; +} + +export function DownloadingImage(): JSX.Element { + const mediaItem = createMediaItem({ + contentType: IMAGE_JPEG, + attachment: { + contentType: IMAGE_JPEG, + size: 123000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + isPermanentlyUndownloadable: false, + totalDownloaded: 20300, + pending: true, + }, }); const props = createProps({ @@ -81,7 +209,7 @@ export function MissingImage(): JSX.Element { export function MissingVideo(): JSX.Element { const mediaItem = createMediaItem({ - contentType: stringToMIMEType('video/mp4'), + contentType: VIDEO_MP4, }); const props = createProps({ @@ -93,8 +221,8 @@ export function MissingVideo(): JSX.Element { export function BrokenImage(): JSX.Element { const mediaItem = createMediaItem({ - thumbnailObjectUrl: '/missing-fixtures/nope.jpg', - contentType: stringToMIMEType('image/jpeg'), + objectURL: '/missing-fixtures/nope.jpg', + contentType: IMAGE_JPEG, }); const props = createProps({ @@ -106,8 +234,8 @@ export function BrokenImage(): JSX.Element { export function BrokenVideo(): JSX.Element { const mediaItem = createMediaItem({ - thumbnailObjectUrl: '/missing-fixtures/nope.mp4', - contentType: stringToMIMEType('video/mp4'), + objectURL: '/missing-fixtures/nope.mp4', + contentType: VIDEO_MP4, }); const props = createProps({ @@ -119,7 +247,7 @@ export function BrokenVideo(): JSX.Element { export function OtherContentType(): JSX.Element { const mediaItem = createMediaItem({ - contentType: stringToMIMEType('application/text'), + contentType: APPLICATION_OCTET_STREAM, }); const props = createProps({ diff --git a/ts/components/conversation/media-gallery/MediaGridItem.tsx b/ts/components/conversation/media-gallery/MediaGridItem.tsx index 7205bae9e04..7c6e68d883a 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.tsx @@ -1,105 +1,167 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useState } from 'react'; -import classNames from 'classnames'; +import React from 'react'; import type { ReadonlyDeep } from 'type-fest'; -import { - isImageTypeSupported, - isVideoTypeSupported, -} from '../../../util/GoogleChrome'; -import type { LocalizerType } from '../../../types/Util'; +import { formatFileSize } from '../../../util/formatFileSize'; +import type { LocalizerType, ThemeType } from '../../../types/Util'; import type { MediaItemType } from '../../../types/MediaItem'; -import { createLogger } from '../../../logging/log'; +import type { AttachmentForUIType } from '../../../types/Attachment'; +import { + getAlt, + getUrl, + defaultBlurHash, + isGIF, +} from '../../../types/Attachment'; +import { ImageOrBlurhash } from '../../ImageOrBlurhash'; +import { SpinnerV2 } from '../../SpinnerV2'; +import { tw } from '../../../axo/tw'; +import { AxoSymbol } from '../../../axo/AxoSymbol'; -const log = createLogger('MediaGridItem'); - -export type Props = { +export type Props = Readonly<{ mediaItem: ReadonlyDeep; - onClick?: () => void; + onClick?: (ev: React.MouseEvent) => void; i18n: LocalizerType; -}; + theme?: ThemeType; +}>; -function MediaGridItemContent(props: Props) { - const { mediaItem, i18n } = props; - const { attachment, contentType } = mediaItem; +export function MediaGridItem(props: Props): JSX.Element { + const { + mediaItem: { attachment }, + i18n, + theme, + onClick, + } = props; - const [imageBroken, setImageBroken] = useState(false); + const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme); + const url = getUrl(attachment); - const handleImageError = useCallback(() => { - log.info('Image failed to load; failing over to placeholder'); - setImageBroken(true); - }, []); + const { width, height } = attachment; - if (!attachment) { - return null; + const imageOrBlurHash = ( + + ); + + let label: string; + if (attachment.url || attachment.incrementalUrl) { + label = i18n('icu:imageOpenAlt'); + } else if (attachment.pending) { + label = i18n('icu:cancelDownload'); + } else { + label = i18n('icu:startDownload'); } - if (contentType && isImageTypeSupported(contentType)) { - if (imageBroken || !mediaItem.thumbnailObjectUrl) { - return ( -
- ); - } + return ( + + ); +} + +type SpinnerOverlayProps = Readonly<{ + attachment: AttachmentForUIType; +}>; + +function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined { + const { attachment } = props; + + if (attachment.url != null || attachment.incrementalUrl != null) { + return undefined; } - if (contentType && isVideoTypeSupported(contentType)) { - if (imageBroken || !mediaItem.thumbnailObjectUrl) { - return ( -
- ); - } + const spinnerValue = + (!attachment.incrementalUrl && + attachment.size && + attachment.totalDownloaded) || + undefined; - return ( -
- {i18n('icu:lightboxImageAlt')} + {attachment.pending && ( + + )} +
+ -
-
-
- ); +
+ ); +} + +type MetadataOverlayProps = Readonly<{ + i18n: LocalizerType; + attachment: AttachmentForUIType; +}>; + +function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined { + const { i18n, attachment } = props; + + const canBeShown = + attachment.url != null || attachment.incrementalUrl != null; + if (canBeShown && !isGIF([attachment])) { + return undefined; + } + + let text: string; + if (isGIF([attachment]) && canBeShown) { + text = i18n('icu:message--getNotificationText--gif'); + } else { + text = formatFileSize(attachment.size); } return (
- ); -} - -export function MediaGridItem(props: Props): JSX.Element { - const { onClick } = props; - return ( - + > + + {text} + +
); } diff --git a/ts/components/conversation/media-gallery/utils/mocks.ts b/ts/components/conversation/media-gallery/utils/mocks.ts index 744eba6545c..ffc39712962 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import { random, range, sample, sortBy } from 'lodash'; -import type { MIMEType } from '../../../../types/MIME'; +import { type MIMEType, IMAGE_JPEG } from '../../../../types/MIME'; import type { MediaItemType } from '../../../../types/MediaItem'; +import { randomBlurHash } from '../../../../util/randomBlurHash'; +import { SignalService } from '../../../../protobuf'; const DAY_MS = 24 * 60 * 60 * 1000; export const days = (n: number): number => n * DAY_MS; @@ -16,6 +18,7 @@ const contentTypes = { mp4: 'video/mp4', docx: 'application/text', pdf: 'application/pdf', + exe: 'application/exe', txt: 'application/text', } as unknown as Record; @@ -27,27 +30,42 @@ function createRandomFile( const contentType = contentTypes[fileExtension]; const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`; + const isDownloaded = Math.random() > 0.4; + return { - contentType, message: { conversationId: '123', + type: 'incoming', id: random(Date.now()).toString(), receivedAt: Math.floor(Math.random() * 10), receivedAtMs: random(startTime, startTime + timeWindow), - attachments: [], sentAt: Date.now(), }, attachment: { - url: '', + url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined, + path: isDownloaded ? 'abc' : undefined, + screenshot: + fileExtension === 'mp4' + ? { + url: isDownloaded + ? '/fixtures/cat-screenshot-3x4.png' + : undefined, + contentType: IMAGE_JPEG, + } + : undefined, + flags: + fileExtension === 'mp4' && Math.random() > 0.5 + ? SignalService.AttachmentPointer.Flags.GIF + : 0, + width: 400, + height: 300, fileName, size: random(1000, 1000 * 1000 * 50), contentType, + blurHash: randomBlurHash(), + isPermanentlyUndownloadable: false, }, index: 0, - thumbnailObjectUrl: `https://placekitten.com/${random(50, 150)}/${random( - 50, - 150 - )}`, }; } @@ -64,7 +82,12 @@ export function createRandomDocuments( startTime: number, timeWindow: number ): Array { - return createRandomFiles(startTime, timeWindow, ['docx', 'pdf', 'txt']); + return createRandomFiles(startTime, timeWindow, [ + 'docx', + 'pdf', + 'exe', + 'txt', + ]); } export function createRandomMedia( diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 3c3f3c2bfbc..95625f485dd 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -50,6 +50,8 @@ import type { } from '../types/GroupSendEndorsements'; import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; +import type { AttachmentType } from '../types/Attachment'; +import type { MediaItemMessageType } from '../types/MediaItem'; import type { GifType } from '../components/fun/panels/FunPanelGifs'; import type { NotificationProfileType } from '../types/NotificationProfile'; import type { DonationReceipt } from '../types/Donations'; @@ -566,9 +568,26 @@ export type BackupAttachmentDownloadProgress = { completedBytes: number; }; +export type GetOlderMediaOptionsType = Readonly<{ + conversationId: string; + limit: number; + messageId?: string; + receivedAt?: number; + sentAt?: number; +}>; + +export type MediaItemDBType = Readonly<{ + attachment: AttachmentType; + index: number; + message: MediaItemMessageType; +}>; + export const MESSAGE_ATTACHMENT_COLUMNS = [ 'messageId', 'conversationId', + 'messageType', + 'receivedAt', + 'receivedAtMs', 'sentAt', 'attachmentType', 'orderInMessage', @@ -612,6 +631,7 @@ export const MESSAGE_ATTACHMENT_COLUMNS = [ 'storyTextAttachmentJson', 'localBackupPath', 'isCorrupted', + 'isViewOnce', 'backfillError', 'error', 'wasTooBig', @@ -626,6 +646,9 @@ export type MessageAttachmentDBType = { orderInMessage: number; editHistoryIndex: number | null; conversationId: string; + messageType: string; + receivedAt: number; + receivedAtMs: number | null; sentAt: number; clientUuid: string | null; size: number; @@ -667,6 +690,7 @@ export type MessageAttachmentDBType = { storyTextAttachmentJson: string | null; localBackupPath: string | null; isCorrupted: 1 | 0 | null; + isViewOnce: 1 | 0 | null; backfillError: 1 | 0 | null; error: 1 | 0 | null; wasTooBig: 1 | 0 | null; @@ -766,6 +790,8 @@ type ReadableInterface = { maxTimestamp: number ) => Array; // getOlderMessagesByConversation is JSON on server, full message on Client + hasMedia: (conversationId: string) => boolean; + getOlderMedia: (options: GetOlderMediaOptionsType) => Array; getAllStories: (options: { conversationId?: string; sourceServiceId?: ServiceIdString; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 52c18cfe99b..d3ab63bd022 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -88,6 +88,7 @@ import { import { hydrateMessage, hydrateMessages, + convertAttachmentDBFieldsToAttachmentType, getAttachmentReferencesForMessages, ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX, } from './hydration'; @@ -148,10 +149,12 @@ import type { GetConversationRangeCenteredOnMessageResultType, GetKnownMessageAttachmentsResultType, GetNearbyMessageFromDeletedSetOptionsType, + GetOlderMediaOptionsType, GetRecentStoryRepliesOptionsType, GetUnreadByConversationAndMarkReadResultType, IdentityKeyIdType, ItemKeyType, + MediaItemDBType, MessageAttachmentsCursorType, MessageCursorType, MessageMetricsType, @@ -434,6 +437,9 @@ export const DataReader: ServerReadableInterface = { getCallHistoryGroups, hasGroupCallHistoryMessage, + hasMedia, + getOlderMedia, + getAllNotificationProfiles, getNotificationProfileById, @@ -2481,17 +2487,29 @@ function saveMessageAttachmentsForRootOrEditedVersion( conversationId: string; sent_at: number; } & Pick< - MessageAttributesType, + MessageType, | 'attachments' | 'bodyAttachment' | 'contact' | 'preview' | 'quote' + | 'type' | 'sticker' + | 'isViewOnce' + | 'received_at' + | 'received_at_ms' >, { editHistoryIndex }: { editHistoryIndex: number | null } ) { - const { id: messageId, conversationId, sent_at: sentAt } = message; + const { + id: messageId, + type: messageType, + conversationId, + sent_at: sentAt, + received_at: receivedAt, + received_at_ms: receivedAtMs, + isViewOnce, + } = message; const mainAttachments = message.attachments; if (mainAttachments) { @@ -2500,12 +2518,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'attachment', attachment, orderInMessage: i, editHistoryIndex, + isViewOnce, }); } } @@ -2515,12 +2537,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'long-message', attachment: bodyAttachment, orderInMessage: 0, editHistoryIndex, + isViewOnce, }); } @@ -2534,12 +2560,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'preview', attachment, orderInMessage: i, editHistoryIndex, + isViewOnce, }); } } @@ -2554,12 +2584,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'quote', attachment: attachment.thumbnail, orderInMessage: i, editHistoryIndex, + isViewOnce, }); } } @@ -2576,12 +2610,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'contact', attachment, orderInMessage: i, editHistoryIndex, + isViewOnce, }); } } @@ -2591,12 +2629,16 @@ function saveMessageAttachmentsForRootOrEditedVersion( saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType: 'sticker', attachment: stickerAttachment, orderInMessage: 0, editHistoryIndex, + isViewOnce, }); } } @@ -2620,8 +2662,10 @@ function saveMessageAttachments( db, { id: message.id, + type: message.type, conversationId: message.conversationId, sent_at: editHistory.timestamp, + isViewOnce: message.isViewOnce, ...editHistory, }, { editHistoryIndex: idx } @@ -2632,30 +2676,41 @@ function saveMessageAttachments( function saveMessageAttachment({ db, messageId, + messageType, conversationId, sentAt, + receivedAt, + receivedAtMs, attachmentType, attachment, orderInMessage, editHistoryIndex, + isViewOnce, }: { db: WritableDB; messageId: string; + messageType: string; conversationId: string; sentAt: number; + receivedAt: number; + receivedAtMs: number | undefined; attachmentType: AttachmentDownloadJobTypeType; attachment: AttachmentType; orderInMessage: number; editHistoryIndex: number | null; + isViewOnce: boolean | undefined; }) { const unparsedValues: ShallowNullToUndefined = { messageId, + messageType, editHistoryIndex: editHistoryIndex ?? ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX, attachmentType, orderInMessage, conversationId, sentAt, + receivedAt, + receivedAtMs, clientUuid: attachment.clientUuid, size: attachment.size, contentType: attachment.contentType, @@ -2700,6 +2755,7 @@ function saveMessageAttachment({ wasTooBig: convertOptionalBooleanToInteger(attachment.wasTooBig), backfillError: convertOptionalBooleanToInteger(attachment.backfillError), isCorrupted: convertOptionalBooleanToInteger(attachment.isCorrupted), + isViewOnce: convertOptionalBooleanToInteger(isViewOnce), copiedFromQuotedAttachment: 'copied' in attachment ? convertOptionalBooleanToInteger(attachment.copied) @@ -5005,6 +5061,94 @@ function hasGroupCallHistoryMessage( return exists === 1; } +function hasMedia(db: ReadableDB, conversationId: string): boolean { + const [query, params] = sql` + SELECT EXISTS( + SELECT 1 FROM message_attachments + INDEXED BY message_attachments_getOlderMedia + WHERE + conversationId IS ${conversationId} AND + editHistoryIndex IS -1 AND + attachmentType IS 'attachment' AND + messageType IN ('incoming', 'outgoing') AND + isViewOnce IS NOT 1 AND + contentType IS NOT NULL AND + contentType IS NOT '' AND + contentType IS NOT 'text/x-signal-plain' AND + contentType NOT LIKE 'audio/%' + ); + `; + const exists = db.prepare(query, { pluck: true }).get(params); + + return exists === 1; +} + +function getOlderMedia( + db: ReadableDB, + { + conversationId, + limit, + messageId, + receivedAt: maxReceivedAt = Number.MAX_VALUE, + sentAt: maxSentAt = Number.MAX_VALUE, + }: GetOlderMediaOptionsType +): Array { + const timeFilters = { + first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`, + second: sqlFragment`receivedAt < ${maxReceivedAt}`, + }; + + const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment` + SELECT + * + FROM message_attachments + INDEXED BY message_attachments_getOlderMedia + WHERE + conversationId IS ${conversationId} AND + editHistoryIndex IS -1 AND + attachmentType IS 'attachment' AND + ( + ${timeFilter} + ) AND + ( + -- see 'isVisualMedia' in ts/types/Attachment.ts + contentType LIKE 'image/%' OR + contentType LIKE 'video/%' + ) AND + isViewOnce IS NOT 1 AND + messageType IN ('incoming', 'outgoing') AND + (${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null}) + ORDER BY receivedAt DESC, sentAt DESC + LIMIT ${limit} + `; + + const [query, params] = sql` + SELECT first.* FROM (${createQuery(timeFilters.first)}) as first + UNION ALL + SELECT second.* FROM (${createQuery(timeFilters.second)}) as second + `; + + const results: Array = db.prepare(query).all(params); + + return results.map(attachment => { + const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } = + attachment; + + return { + message: { + id: attachment.messageId, + type: messageType as 'incoming' | 'outgoing', + conversationId, + receivedAt, + receivedAtMs: receivedAtMs ?? undefined, + sentAt, + }, + index: orderInMessage, + attachment: convertAttachmentDBFieldsToAttachmentType(attachment), + }; + }); +} + function _markCallHistoryMissed( db: WritableDB, callIds: ReadonlyArray diff --git a/ts/sql/hydration.ts b/ts/sql/hydration.ts index a0adb6cc9ce..1c2be0046f2 100644 --- a/ts/sql/hydration.ts +++ b/ts/sql/hydration.ts @@ -330,7 +330,7 @@ function hydrateMessageRootOrRevisionWithAttachments< return hydratedMessage; } -function convertAttachmentDBFieldsToAttachmentType( +export function convertAttachmentDBFieldsToAttachmentType( dbFields: MessageAttachmentDBType ): AttachmentType { const messageAttachment = shallowDropNull(dbFields); diff --git a/ts/sql/migrations/1450-all-media.ts b/ts/sql/migrations/1450-all-media.ts new file mode 100644 index 00000000000..e2ac73ca080 --- /dev/null +++ b/ts/sql/migrations/1450-all-media.ts @@ -0,0 +1,43 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { WritableDB } from '../Interface'; + +export default function updateToSchemaVersion1450(db: WritableDB): void { + db.exec(` + ALTER TABLE message_attachments + ADD COLUMN messageType TEXT; + ALTER TABLE message_attachments + ADD COLUMN receivedAt INTEGER; + ALTER TABLE message_attachments + ADD COLUMN receivedAtMs INTEGER; + ALTER TABLE message_attachments + ADD COLUMN isViewOnce INTEGER; + `); + + // Backfill + db.exec(` + UPDATE message_attachments + SET + messageType = messages.type, + receivedAt = messages.received_at, + receivedAtMs = messages.received_at_ms, + isViewOnce = messages.isViewOnce + FROM ( + SELECT id, type, received_at, received_at_ms, isViewOnce + FROM messages + ) AS messages + WHERE + message_attachments.messageId IS messages.id + `); + + // Index + db.exec(` + CREATE INDEX message_attachments_getOlderMedia ON message_attachments + (conversationId, attachmentType, receivedAt DESC, sentAt DESC) + WHERE + editHistoryIndex IS -1 AND + messageType IN ('incoming', 'outgoing') AND + isViewOnce IS NOT 1 + `); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 96c7143a42d..61c51f1468d 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -120,6 +120,7 @@ import updateToSchemaVersion1410 from './1410-remove-wallpaper'; import updateToSchemaVersion1420 from './1420-backup-downloads'; import updateToSchemaVersion1430 from './1430-call-links-epoch-id'; import updateToSchemaVersion1440 from './1440-chat-folders'; +import updateToSchemaVersion1450 from './1450-all-media'; import { DataWriter } from '../Server'; @@ -1595,6 +1596,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1420, update: updateToSchemaVersion1420 }, { version: 1430, update: updateToSchemaVersion1430 }, { version: 1440, update: updateToSchemaVersion1440 }, + { version: 1450, update: updateToSchemaVersion1450 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/messageAttachments.ts b/ts/sql/server/messageAttachments.ts index 60e4343a118..75a13951387 100644 --- a/ts/sql/server/messageAttachments.ts +++ b/ts/sql/server/messageAttachments.ts @@ -33,15 +33,18 @@ const permissiveOptionalBool = z export const permissiveMessageAttachmentSchema = z.object({ // Fields required to be NOT NULL messageId: z.string(), + messageType: z.string(), editHistoryIndex: z.number(), attachmentType: attachmentDownloadTypeSchema, orderInMessage: z.number(), conversationId: z.string(), sentAt: z.number().catch(0), + receivedAt: z.number().catch(0), size: z.number().catch(0), contentType: z.string().catch(APPLICATION_OCTET_STREAM), // Fields allowing NULL + receivedAtMs: permissiveNumberOrNull, path: permissiveStringOrNull, clientUuid: permissiveStringOrNull, localKey: permissiveStringOrNull, @@ -82,6 +85,7 @@ export const permissiveMessageAttachmentSchema = z.object({ wasTooBig: permissiveOptionalBool, backfillError: permissiveOptionalBool, isCorrupted: permissiveOptionalBool, + isViewOnce: permissiveOptionalBool, copiedFromQuotedAttachment: permissiveOptionalBool, version: permissiveAttachmentVersion, pending: permissiveOptionalBool, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 5051d79d152..2b854b07e35 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -60,7 +60,6 @@ import type { ConversationAttributesType, DraftEditMessageType, LastMessageStatus, - MessageAttributesType, ReadonlyMessageAttributesType, } from '../../model-types.d'; import type { @@ -215,7 +214,6 @@ import { } from '../../messages/MessageSendState'; import { markFailed } from '../../test-node/util/messageFailures'; import { cleanupMessages } from '../../util/cleanup'; -import { MessageModel } from '../../models/messages'; import type { ConversationModel } from '../../models/conversations'; import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent'; @@ -1169,7 +1167,6 @@ export const actions = { loadNewerMessages, loadNewestMessages, loadOlderMessages, - loadRecentMediaItems, markAttachmentAsCorrupted, markMessageRead, markOpenConversationRead, @@ -3996,73 +3993,6 @@ function initiateMigrationToGroupV2(conversationId: string): NoopActionType { }; } -function loadRecentMediaItems( - conversationId: string, - limit: number -): ThunkAction { - return async dispatch => { - const messages: Array = - await DataReader.getOlderMessagesByConversation({ - conversationId, - limit, - requireVisualMediaAttachments: true, - storyId: undefined, - includeStoryReplies: false, - }); - - // Cache these messages in memory to ensure Lightbox can find them - messages.forEach(message => { - window.MessageCache.register(new MessageModel(message)); - }); - - let index = 0; - const recentMediaItems = messages - .filter(message => message.attachments !== undefined) - .reduce( - (acc, message) => [ - ...acc, - ...(message.attachments || []).map( - (attachment: AttachmentType): MediaItemType => { - const { thumbnail } = attachment; - - const result = { - objectURL: attachment.path - ? getLocalAttachmentUrl(attachment) - : '', - thumbnailObjectUrl: thumbnail?.path - ? getLocalAttachmentUrl(thumbnail) - : '', - contentType: attachment.contentType, - index, - attachment, - message: { - attachments: message.attachments || [], - conversationId: - window.ConversationController.get(message.sourceServiceId) - ?.id || message.conversationId, - id: message.id, - receivedAt: message.received_at, - receivedAtMs: Number(message.received_at_ms), - sentAt: message.sent_at, - }, - }; - - index += 1; - - return result; - } - ), - ], - [] as Array - ); - - dispatch({ - type: 'SET_RECENT_MEDIA_ITEMS', - payload: { id: conversationId, recentMediaItems }, - }); - }; -} - export type SaveAttachmentActionCreatorType = ReadonlyDeep< (attachment: AttachmentType, timestamp?: number, index?: number) => unknown >; diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts index e4378f088fc..00a409182a7 100644 --- a/ts/state/ducks/lightbox.ts +++ b/ts/state/ducks/lightbox.ts @@ -21,7 +21,6 @@ import { getMessageById } from '../../messages/getMessageById'; import type { ReadonlyMessageAttributesType } from '../../model-types.d'; import { getUndownloadedAttachmentSignature, - isGIF, isIncremental, } from '../../types/Attachment'; import { @@ -32,7 +31,7 @@ import { getLocalAttachmentUrl, AttachmentDisposition, } from '../../util/getLocalAttachmentUrl'; -import { isTapToView } from '../selectors/message'; +import { isTapToView, getPropsForAttachment } from '../selectors/message'; import { SHOW_TOAST } from './toast'; import { ToastType } from '../../types/Toast'; import { @@ -202,25 +201,26 @@ function showLightboxForViewOnceMedia( const { path: tempPath } = await copyAttachmentIntoTempDirectory(absolutePath); const tempAttachment = { - ...firstAttachment, + ...getPropsForAttachment( + firstAttachment, + 'attachment', + message.attributes + ), path: tempPath, }; + tempAttachment.url = getLocalAttachmentUrl(tempAttachment, { + disposition: AttachmentDisposition.Temporary, + }); await markViewOnceMessageViewed(message); - const { contentType } = tempAttachment; - const media = [ { attachment: tempAttachment, - objectURL: getLocalAttachmentUrl(tempAttachment, { - disposition: AttachmentDisposition.Temporary, - }), - contentType, index: 0, message: { - attachments: message.get('attachments') || [], id: message.get('id'), + type: message.get('type'), conversationId: message.get('conversationId'), receivedAt: message.get('received_at'), receivedAtMs: Number(message.get('received_at_ms')), @@ -307,7 +307,6 @@ function showLightbox(opts: { } const attachments = filterValidAttachments(message.attributes); - const loop = isGIF(attachments); const authorId = window.ConversationController.lookupOrCreate({ @@ -320,33 +319,25 @@ function showLightbox(opts: { const media = attachments .map((item, index) => ({ - objectURL: item.path ? getLocalAttachmentUrl(item) : undefined, - incrementalObjectUrl: - isIncremental(item) && item.downloadPath - ? getLocalAttachmentUrl(item, { - disposition: AttachmentDisposition.Download, - }) - : undefined, path: item.path, - contentType: item.contentType, - loop, index, message: { - attachments: message.get('attachments') || [], id: messageId, + type: message.get('type'), conversationId: authorId, receivedAt, receivedAtMs: Number(message.get('received_at_ms')), sentAt, }, - attachment: item, - thumbnailObjectUrl: item.thumbnail?.path - ? getLocalAttachmentUrl(item.thumbnail) - : undefined, + attachment: getPropsForAttachment( + item, + 'attachment', + message.attributes + ), size: item.size, totalDownloaded: item.totalDownloaded, })) - .filter(item => item.objectURL || item.incrementalObjectUrl); + .filter(item => item.attachment.url || item.attachment.incrementalUrl); if (!media.length) { log.error( diff --git a/ts/state/ducks/mediaGallery.ts b/ts/state/ducks/mediaGallery.ts index 649be0798ba..7a35de0a5e6 100644 --- a/ts/state/ducks/mediaGallery.ts +++ b/ts/state/ducks/mediaGallery.ts @@ -6,22 +6,17 @@ import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; import { createLogger } from '../../logging/log'; -import * as Errors from '../../types/errors'; import { DataReader } from '../../sql/Client'; +import type { MediaItemDBType } from '../../sql/Interface'; 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 { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl'; -import { getMessageIdForLogging } from '../../util/idForLogging'; import { useBoundActions } from '../../hooks/useBoundActions'; -import type { AttachmentType } from '../../types/Attachment'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { ConversationUnloadedActionType, @@ -29,33 +24,22 @@ import type { MessageDeletedActionType, MessageExpiredActionType, } from './conversations'; -import type { MIMEType } from '../../types/MIME'; import type { MediaItemType } from '../../types/MediaItem'; import type { StateType as RootStateType } from '../reducer'; -import type { MessageAttributesType } from '../../model-types'; -import { MessageModel } from '../../models/messages'; -import { isTapToView } from '../selectors/message'; +import type { MessageAttributesType, MessageType } from '../../model-types'; +import { isTapToView, getPropsForAttachment } from '../selectors/message'; const log = createLogger('mediaGallery'); type MediaItemMessage = ReadonlyDeep<{ - attachments: Array; // Note that this reflects the sender, and not the parent conversation conversationId: string; + type: MessageType; id: string; receivedAt: number; receivedAtMs: number; sentAt: number; }>; -type MediaType = ReadonlyDeep<{ - path: string; - objectURL: string; - thumbnailObjectUrl?: string; - contentType: MIMEType; - index: number; - attachment: AttachmentType; - message: MediaItemMessage; -}>; export type MediaGalleryStateType = ReadonlyDeep<{ conversationId: string | undefined; @@ -63,7 +47,7 @@ export type MediaGalleryStateType = ReadonlyDeep<{ haveOldestDocument: boolean; haveOldestMedia: boolean; loading: boolean; - media: ReadonlyArray; + media: ReadonlyArray; }>; const FETCH_CHUNK_COUNT = 50; @@ -78,14 +62,14 @@ type InitialLoadActionType = ReadonlyDeep<{ payload: { conversationId: string; documents: ReadonlyArray; - media: ReadonlyArray; + media: ReadonlyArray; }; }>; type LoadMoreMediaActionType = ReadonlyDeep<{ type: typeof LOAD_MORE_MEDIA; payload: { conversationId: string; - media: ReadonlyArray; + media: ReadonlyArray; }; }>; type LoadMoreDocumentsActionType = ReadonlyDeep<{ @@ -113,7 +97,9 @@ type MediaGalleryActionType = ReadonlyDeep< | SetLoadingActionType >; -function _sortMedia(media: ReadonlyArray): ReadonlyArray { +function _sortMedia( + media: ReadonlyArray +): ReadonlyArray { return orderBy(media, [ 'message.receivedAt', 'message.sentAt', @@ -130,13 +116,13 @@ function _getMediaItemMessage( message: ReadonlyDeep ): MediaItemMessage { return { - attachments: message.attachments || [], conversationId: window.ConversationController.lookupOrCreate({ serviceId: message.sourceServiceId, e164: message.source, reason: 'conversation_view.showAllMedia', })?.id || message.conversationId, + type: message.type, id: message.id, receivedAt: message.received_at, receivedAtMs: Number(message.received_at_ms), @@ -145,99 +131,41 @@ function _getMediaItemMessage( } function _cleanVisualAttachments( - rawMedia: ReadonlyDeep> -): ReadonlyArray { - return rawMedia - .flatMap(message => { - let index = 0; - - // Also checked via the DB query - if (isTapToView(message.attributes)) { - return []; - } - - return (message.get('attachments') || []).map( - (attachment: AttachmentType): MediaType | undefined => { - if ( - !attachment.path || - !attachment.thumbnail || - isDownloading(attachment) || - hasFailed(attachment) - ) { - return; - } - - const { thumbnail } = attachment; - const result = { - path: attachment.path, - objectURL: getLocalAttachmentUrl(attachment), - thumbnailObjectUrl: thumbnail?.path - ? getLocalAttachmentUrl(thumbnail) - : undefined, - contentType: attachment.contentType, - index, - attachment, - message: _getMediaItemMessage(message.attributes), - }; - - index += 1; - - return result; - } - ); - }) - .filter(isNotNil); + rawMedia: ReadonlyArray +): ReadonlyArray { + return rawMedia.map(({ message, index, attachment }) => { + return { + index, + attachment: getPropsForAttachment(attachment, 'attachment', message), + message, + }; + }); } function _cleanFileAttachments( - rawDocuments: ReadonlyDeep> + rawDocuments: ReadonlyDeep> ): ReadonlyArray { return rawDocuments .map(message => { - if (isTapToView(message.attributes)) { + if (isTapToView(message)) { return; } - const attachments = message.get('attachments') || []; + + const attachments = message.attachments || []; const attachment = attachments[0]; if (!attachment) { return; } return { - contentType: attachment.contentType, index: 0, - attachment, - message: { - ..._getMediaItemMessage(message.attributes), - attachments: [attachment], - }, + attachment: getPropsForAttachment(attachment, 'attachment', message), + message: _getMediaItemMessage(message), }; }) .filter(isNotNil); } -async function _upgradeMessages( - messages: ReadonlyArray -): Promise { - // We upgrade these messages so they are sure to have thumbnails - await Promise.all( - messages.map(async message => { - try { - await window.MessageCache.upgradeSchema( - message, - VERSION_NEEDED_FOR_DISPLAY - ); - } catch (error) { - log.warn( - '_upgradeMessages: Failed to upgrade message ' + - `${getMessageIdForLogging(message.attributes)}: ${Errors.toLogFormat(error)}` - ); - return undefined; - } - }) - ); -} - function initialLoad( conversationId: string ): ThunkAction< @@ -252,26 +180,17 @@ function initialLoad( payload: { loading: true }, }); - const rawMedia = ( - await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - requireVisualMediaAttachments: true, - storyId: undefined, - }) - ).map(item => window.MessageCache.register(new MessageModel(item))); - const rawDocuments = ( - await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - requireFileAttachments: true, - storyId: undefined, - }) - ).map(item => window.MessageCache.register(new MessageModel(item))); - - await _upgradeMessages(rawMedia); + const rawMedia = await DataReader.getOlderMedia({ + conversationId, + limit: FETCH_CHUNK_COUNT, + }); + const rawDocuments = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + requireFileAttachments: true, + storyId: undefined, + }); const media = _cleanVisualAttachments(rawMedia); const documents = _cleanFileAttachments(rawDocuments); @@ -319,20 +238,13 @@ function loadMoreMedia( const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message; - const rawMedia = ( - await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - messageId, - receivedAt, - requireVisualMediaAttachments: true, - sentAt, - storyId: undefined, - }) - ).map(item => window.MessageCache.register(new MessageModel(item))); - - await _upgradeMessages(rawMedia); + const rawMedia = await DataReader.getOlderMedia({ + conversationId, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + sentAt, + }); const media = _cleanVisualAttachments(rawMedia); @@ -384,18 +296,16 @@ function loadMoreDocuments( const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message; - const rawDocuments = ( - await DataReader.getOlderMessagesByConversation({ - conversationId, - includeStoryReplies: false, - limit: FETCH_CHUNK_COUNT, - messageId, - receivedAt, - requireFileAttachments: true, - sentAt, - storyId: undefined, - }) - ).map(item => window.MessageCache.register(new MessageModel(item))); + const rawDocuments = await DataReader.getOlderMessagesByConversation({ + conversationId, + includeStoryReplies: false, + limit: FETCH_CHUNK_COUNT, + messageId, + receivedAt, + requireFileAttachments: true, + sentAt, + storyId: undefined, + }); const documents = _cleanFileAttachments(rawDocuments); @@ -519,12 +429,23 @@ export function reducer( const oldestLoadedMedia = state.media[0]; const oldestLoadedDocument = state.documents[0]; - const newMedia = _cleanVisualAttachments([ - window.MessageCache.register(new MessageModel(message)), - ]); - const newDocuments = _cleanFileAttachments([ - window.MessageCache.register(new MessageModel(message)), - ]); + const newMedia = _cleanVisualAttachments( + (message.attachments ?? []).map((attachment, index) => { + return { + index, + attachment, + message: { + id: message.id, + type: message.type, + conversationId: message.conversationId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms, + sentAt: message.sent_at, + }, + }; + }) + ); + const newDocuments = _cleanFileAttachments([message]); let { documents, haveOldestDocument, haveOldestMedia, media } = state; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 6a045a5487b..5e0d4923eed 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -66,7 +66,11 @@ import type { AttachmentForUIType, AttachmentType, } from '../../types/Attachment'; -import { isVoiceMessage, defaultBlurHash } from '../../types/Attachment'; +import { + isVoiceMessage, + isIncremental, + defaultBlurHash, +} from '../../types/Attachment'; import type { AttachmentDownloadJobTypeType } from '../../types/AttachmentDownload'; import { type DefaultConversationColorType } from '../../types/Colors'; import { ReadStatus } from '../../messages/MessageReadStatus'; @@ -79,7 +83,10 @@ import { isMoreRecentThan } from '../../util/timestamp'; import * as iterables from '../../util/iterables'; import { strictAssert } from '../../util/assert'; import { canEditMessage } from '../../util/canEditMessage'; -import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl'; +import { + getLocalAttachmentUrl, + AttachmentDisposition, +} from '../../util/getLocalAttachmentUrl'; import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager'; import { getAccountSelector } from './accounts'; @@ -1853,6 +1860,12 @@ export function getPropsForAttachment( isVoiceMessage: isVoiceMessage(attachment), pending, url: path ? getLocalAttachmentUrl(attachment) : undefined, + incrementalUrl: + isIncremental(attachment) && attachment.downloadPath + ? getLocalAttachmentUrl(attachment, { + disposition: AttachmentDisposition.Download, + }) + : undefined, thumbnailFromBackup: thumbnailFromBackup?.path ? { ...thumbnailFromBackup, diff --git a/ts/state/smart/AllMedia.tsx b/ts/state/smart/AllMedia.tsx index 02ac06768eb..bfa66097f9a 100644 --- a/ts/state/smart/AllMedia.tsx +++ b/ts/state/smart/AllMedia.tsx @@ -4,6 +4,7 @@ import React, { memo } from 'react'; import { useSelector } from 'react-redux'; import { MediaGallery } from '../../components/conversation/media-gallery/MediaGallery'; import { getMediaGalleryState } from '../selectors/mediaGallery'; +import { getIntl, getTheme } from '../selectors/user'; import { useConversationsActions } from '../ducks/conversations'; import { useLightboxActions } from '../ducks/lightbox'; import { useMediaGalleryActions } from '../ducks/mediaGallery'; @@ -19,15 +20,22 @@ export const SmartAllMedia = memo(function SmartAllMedia({ useSelector(getMediaGalleryState); const { initialLoad, loadMoreMedia, loadMoreDocuments } = useMediaGalleryActions(); - const { saveAttachment } = useConversationsActions(); + const { + saveAttachment, + kickOffAttachmentDownload, + cancelAttachmentDownload, + } = useConversationsActions(); const { showLightbox } = useLightboxActions(); + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); return ( ); diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index a8133542eeb..7f11ec81cb6 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { sortBy } from 'lodash'; -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails'; import { @@ -40,8 +40,9 @@ import { useConversationsActions } from '../ducks/conversations'; import { useCallingActions } from '../ducks/calling'; import { useSearchActions } from '../ducks/search'; import { useGlobalModalActions } from '../ducks/globalModals'; -import { useLightboxActions } from '../ducks/lightbox'; import { isSignalConversation } from '../../util/isSignalConversation'; +import { drop } from '../../util/drop'; +import { DataReader } from '../../sql/Client'; export type SmartConversationDetailsProps = { conversationId: string; @@ -109,7 +110,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ deleteAvatarFromDisk, getProfilesForConversation, leaveGroup, - loadRecentMediaItems, pushPanelForConversation, replaceAvatar, saveAvatarToDisk, @@ -132,7 +132,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ toggleEditNicknameAndNoteModal, toggleSafetyNumberModal, } = useGlobalModalActions(); - const { showLightbox } = useLightboxActions(); const conversation = conversationSelector(conversationId); assertDev( @@ -177,6 +176,26 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ toggleEditNicknameAndNoteModal({ conversationId }); }, [conversationId, toggleEditNicknameAndNoteModal]); + const [hasMedia, setHasMedia] = useState(false); + + useEffect(() => { + let isCanceled = false; + + drop( + (async () => { + const result = await DataReader.hasMedia(conversationId); + if (isCanceled) { + return; + } + setHasMedia(result); + })() + ); + + return () => { + isCanceled = true; + }; + }, [conversationId]); + return ( startAvatarDownload(conversationId)} theme={theme} toggleAboutContactModal={toggleAboutContactModal} diff --git a/ts/state/smart/ConversationPanel.tsx b/ts/state/smart/ConversationPanel.tsx index c1bd0d3620d..be337b7e5f4 100644 --- a/ts/state/smart/ConversationPanel.tsx +++ b/ts/state/smart/ConversationPanel.tsx @@ -29,7 +29,6 @@ import { SmartStickerManager } from './StickerManager'; import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType'; import { getIntl } from '../selectors/user'; import { - getIsPanelAnimating, getPanelInformation, getWasPanelAnimated, } from '../selectors/conversations'; @@ -111,7 +110,6 @@ export const ConversationPanel = memo(function ConversationPanel({ const i18n = useSelector(getIntl); const isRTL = i18n.getLocaleDirection() === 'rtl'; - const isAnimating = useSelector(getIsPanelAnimating); const wasAnimated = useSelector(getWasPanelAnimated); const [lastPanelDoneAnimating, setLastPanelDoneAnimating] = @@ -217,16 +215,22 @@ export const ConversationPanel = memo(function ConversationPanel({ <> {activePanel && ( )} {lastPanelDoneAnimating !== prevPanel && ( -
+
)} {prevPanel && lastPanelDoneAnimating !== prevPanel && ( - {isAnimating && prevPanel && ( - + {lastPanelDoneAnimating !== prevPanel && prevPanel && ( + )} -
+
{ return { - objectURL: id, index: 0, message: { + type: 'incoming', conversationId: '1234', id: 'id', receivedAt: date.getTime(), receivedAtMs: date.getTime(), - attachments: [], sentAt: date.getTime(), }, attachment: fakeAttachment({ fileName: 'fileName', contentType: IMAGE_JPEG, - url: 'url', + url: id, }), }; }; @@ -62,35 +61,35 @@ describe('groupMediaItemsByDate', () => { assert.strictEqual(actual[0].type, 'today'); assert.strictEqual(actual[0].mediaItems.length, 2, 'today'); - assert.strictEqual(actual[0].mediaItems[0].objectURL, 'today-1'); - assert.strictEqual(actual[0].mediaItems[1].objectURL, 'today-2'); + assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1'); + assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2'); assert.strictEqual(actual[1].type, 'yesterday'); assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday'); - assert.strictEqual(actual[1].mediaItems[0].objectURL, 'yesterday-1'); - assert.strictEqual(actual[1].mediaItems[1].objectURL, 'yesterday-2'); + assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1'); + assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2'); assert.strictEqual(actual[2].type, 'thisWeek'); assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek'); - assert.strictEqual(actual[2].mediaItems[0].objectURL, 'thisWeek-1'); - assert.strictEqual(actual[2].mediaItems[1].objectURL, 'thisWeek-2'); - assert.strictEqual(actual[2].mediaItems[2].objectURL, 'thisWeek-3'); - assert.strictEqual(actual[2].mediaItems[3].objectURL, 'thisWeek-4'); + assert.strictEqual(actual[2].mediaItems[0].attachment.url, 'thisWeek-1'); + assert.strictEqual(actual[2].mediaItems[1].attachment.url, 'thisWeek-2'); + assert.strictEqual(actual[2].mediaItems[2].attachment.url, 'thisWeek-3'); + assert.strictEqual(actual[2].mediaItems[3].attachment.url, 'thisWeek-4'); assert.strictEqual(actual[3].type, 'thisMonth'); assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth'); - assert.strictEqual(actual[3].mediaItems[0].objectURL, 'thisMonth-1'); - assert.strictEqual(actual[3].mediaItems[1].objectURL, 'thisMonth-2'); + assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1'); + assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2'); assert.strictEqual(actual[4].type, 'yearMonth'); assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024'); - assert.strictEqual(actual[4].mediaItems[0].objectURL, 'mar2024-1'); - assert.strictEqual(actual[4].mediaItems[1].objectURL, 'mar2024-2'); + assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1'); + assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2'); assert.strictEqual(actual[5].type, 'yearMonth'); assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011'); - assert.strictEqual(actual[5].mediaItems[0].objectURL, 'feb2011-1'); - assert.strictEqual(actual[5].mediaItems[1].objectURL, 'feb2011-2'); + assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1'); + assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2'); assert.strictEqual(actual.length, 6, 'total sections'); }); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 67920853a4a..53c1891e4f6 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -88,6 +88,7 @@ export type EphemeralAttachmentFields = { isVoiceMessage?: boolean; /** For messages not already on disk, this will be a data url */ url?: string; + incrementalUrl?: string; screenshotData?: Uint8Array; /** @deprecated Legacy field */ screenshotPath?: string; diff --git a/ts/types/MediaItem.ts b/ts/types/MediaItem.ts index 059b0f12cfa..b8824241705 100644 --- a/ts/types/MediaItem.ts +++ b/ts/types/MediaItem.ts @@ -1,27 +1,20 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReadonlyMessageAttributesType } from '../model-types.d'; -import type { AttachmentType } from './Attachment'; -import type { MIMEType } from './MIME'; +import type { AttachmentForUIType } from './Attachment'; +import type { MessageAttributesType } from '../model-types.d'; -export type MediaItemMessageType = Pick< - ReadonlyMessageAttributesType, - 'attachments' | 'conversationId' | 'id' -> & { +export type MediaItemMessageType = Readonly<{ + id: string; + type: MessageAttributesType['type']; + conversationId: string; receivedAt: number; receivedAtMs?: number; sentAt: number; -}; +}>; export type MediaItemType = { - attachment: AttachmentType; - contentType?: MIMEType; + attachment: AttachmentForUIType; index: number; - loop?: boolean; message: MediaItemMessageType; - objectURL?: string; - incrementalObjectUrl?: string; - thumbnailObjectUrl?: string; - size?: number; }; diff --git a/ts/util/randomBlurHash.ts b/ts/util/randomBlurHash.ts new file mode 100644 index 00000000000..b763d7082b2 --- /dev/null +++ b/ts/util/randomBlurHash.ts @@ -0,0 +1,25 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { encode } from 'blurhash'; + +import { hslToRGB } from './hslToRGB'; + +export function randomBlurHash(): string { + const data = new Uint8ClampedArray(2 * 2 * 4); + for (let i = 0; i < data.byteLength; i += 4) { + const { r, g, b } = hslToRGB(Math.random() * 360, 1, 0.5); + + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 0xff; + } + return encode( + data, + 2, // width + 2, // height + 2, // x components + 2 // y components + ); +}