diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6c634a4311..1316efd539 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6001,7 +6001,7 @@ "description": "This is the number of members in a group" }, "icu:ConversationDetailsMediaList--title": { - "messageformat": "Media and files", + "messageformat": "Media, links, and files", "description": "Title for the show all media button in the conversation details screen" }, "icu:ConversationDetailsMembershipList--title": { @@ -6660,6 +6660,42 @@ "messageformat": "Add group description...", "description": "Placeholder text in the details header for those that can edit the group description" }, + "icu:LinkPreviewItem__alt": { + "messageformat": "Open the link in a browser", + "description": "Alt text for the link preview item button" + }, + "icu:MediaGallery__tab__files": { + "messageformat": "Files", + "description": "Header of the links pane in the media gallery, showing files" + }, + "icu:MediaGallery__tab__links": { + "messageformat": "Links", + "description": "Header of the links pane in the media gallery, showing links" + }, + "icu:MediaGallery__EmptyState__title--media": { + "messageformat": "No Media", + "description": "Title of the empty state view of media gallery for media tab" + }, + "icu:MediaGallery__EmptyState__description--media": { + "messageformat": "Photos, Videos, and GIFs that you send and receive will appear here", + "description": "Description of the empty state view of media gallery for media tab" + }, + "icu:MediaGallery__EmptyState__title--links": { + "messageformat": "No Links", + "description": "Title of the empty state view of media gallery for links tab" + }, + "icu:MediaGallery__EmptyState__description--documents": { + "messageformat": "Links that you send and receive will appear here", + "description": "Description of the empty state view of media gallery for links tab" + }, + "icu:MediaGallery__EmptyState__title--documents": { + "messageformat": "No Files", + "description": "Title of the empty state view of media gallery for files tab" + }, + "icu:MediaGallery__EmptyState__description--links": { + "messageformat": "Files that you send and receive will appear here", + "description": "Description of the empty state view of media gallery for files tab" + }, "icu:MediaQualitySelector--button": { "messageformat": "Select media quality", "description": "aria-label for the media quality selector button" diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 43d484d280..03d89aa12e 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -163,13 +163,15 @@ button.grey { } } -a { - @include mixins.light-theme { - color: variables.$color-ultramarine; - } +@layer base { + a { + @include mixins.light-theme { + color: variables.$color-ultramarine; + } - @include mixins.dark-theme { - color: variables.$color-gray-05; + @include mixins.dark-theme { + color: variables.$color-gray-05; + } } } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 2b9ad72ac0..6343a61dff 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2467,7 +2467,6 @@ button.ConversationDetails__action-button { // Module: Media Gallery .module-media-gallery { - position: relative; display: flex; flex-direction: column; flex-grow: 1; @@ -2505,19 +2504,6 @@ button.ConversationDetails__action-button { flex-direction: column; } -/* Module: Empty State*/ - -.module-empty-state { - display: flex; - justify-content: center; - align-items: center; - flex-grow: 1; - - @include mixins.font-title-1; - - color: variables.$color-gray-45; -} - // Module: Message Request Actions .module-message-request-actions { diff --git a/stylesheets/components/ConversationPanel.scss b/stylesheets/components/ConversationPanel.scss index 4740f5fb5a..a03a297abd 100644 --- a/stylesheets/components/ConversationPanel.scss +++ b/stylesheets/components/ConversationPanel.scss @@ -5,6 +5,9 @@ @use '../variables'; .ConversationPanel { + display: flex; + flex-direction: column; + height: 100%; inset-inline-start: 0; overflow-y: auto; @@ -22,13 +25,18 @@ } &__body { - margin-top: calc( + // Used for centering EmptyState in All Media view + position: relative; + + flex-grow: 1; + padding-top: calc( #{variables.$header-height} + var(--title-bar-drag-area-height) ); padding-inline: 24px; } &__header { + flex-shrink: 0; align-items: center; display: flex; flex-direction: row; diff --git a/ts/components/Lightbox.dom.stories.tsx b/ts/components/Lightbox.dom.stories.tsx index 3977ec6be2..3ac906f490 100644 --- a/ts/components/Lightbox.dom.stories.tsx +++ b/ts/components/Lightbox.dom.stories.tsx @@ -45,6 +45,7 @@ function createMediaItem( fileName: overrideProps.objectURL, url: overrideProps.objectURL, }), + type: 'media', index: 0, message: { conversationId: '1234', @@ -53,6 +54,10 @@ function createMediaItem( receivedAt: 0, receivedAtMs: Date.now(), sentAt: Date.now(), + + // Unused for now + source: undefined, + sourceServiceId: undefined, }, ...overrideProps, }; @@ -86,6 +91,7 @@ export function Multimedia(): JSX.Element { const props = createProps({ media: [ { + type: 'media', attachment: fakeAttachment({ contentType: IMAGE_JPEG, fileName: 'tina-rolf-269345-unsplash.jpg', @@ -101,9 +107,13 @@ export function Multimedia(): JSX.Element { receivedAt: 1, receivedAtMs: Date.now(), sentAt: Date.now(), + // Unused for now + source: undefined, + sourceServiceId: undefined, }, }, { + type: 'media', attachment: fakeAttachment({ contentType: VIDEO_MP4, fileName: 'pixabay-Soap-Bubble-7141.mp4', @@ -117,6 +127,9 @@ export function Multimedia(): JSX.Element { receivedAt: 2, receivedAtMs: Date.now(), sentAt: Date.now(), + // Unused for now + source: undefined, + sourceServiceId: undefined, }, }, createMediaItem({ @@ -139,6 +152,7 @@ export function MissingMedia(): JSX.Element { const props = createProps({ media: [ { + type: 'media', attachment: fakeAttachment({ contentType: IMAGE_JPEG, fileName: 'tina-rolf-269345-unsplash.jpg', @@ -152,6 +166,10 @@ export function MissingMedia(): JSX.Element { receivedAt: 3, receivedAtMs: Date.now(), sentAt: Date.now(), + + // Unused for now + source: undefined, + sourceServiceId: undefined, }, }, ], diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx index 5a72f80a82..2dcc3cd237 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx @@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { Props } from './AttachmentSection.dom.js'; import { AttachmentSection } from './AttachmentSection.dom.js'; +import { LinkPreviewItem } from './LinkPreviewItem.dom.js'; import { createRandomDocuments, createRandomMedia, @@ -21,30 +22,31 @@ export default { component: AttachmentSection, argTypes: { header: { control: { type: 'text' } }, - type: { - control: { - type: 'select', - options: ['media', 'documents'], - }, - }, }, args: { i18n, header: 'Today', - type: 'media', mediaItems: [], + renderLinkPreviewItem: ({ mediaItem, onClick }) => { + return ( + + ); + }, onItemClick: action('onItemClick'), }, } satisfies Meta; export function Documents(args: Props) { const mediaItems = createRandomDocuments(Date.now(), days(1)); - return ( - - ); + return ; } export function Media(args: Props) { const mediaItems = createRandomMedia(Date.now(), days(1)); - return ; + return ; } diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx index d32ef1e446..bfa82cb8fe 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx @@ -1,50 +1,96 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { Fragment } from 'react'; import type { ItemClickEvent } from './types/ItemClickEvent.std.js'; import type { LocalizerType, ThemeType } from '../../../types/Util.std.js'; -import type { MediaItemType } from '../../../types/MediaItem.std.js'; -import { DocumentListItem } from './DocumentListItem.dom.js'; +import type { + GenericMediaItemType, + MediaItemType, + LinkPreviewMediaItemType, +} from '../../../types/MediaItem.std.js'; import { MediaGridItem } from './MediaGridItem.dom.js'; +import { DocumentListItem } from './DocumentListItem.dom.js'; +import type { DataProps as LinkPreviewItemPropsType } from './LinkPreviewItem.dom.js'; import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js'; +import { strictAssert } from '../../../util/assert.std.js'; import { tw } from '../../../axo/tw.dom.js'; export type Props = { header?: string; i18n: LocalizerType; - mediaItems: ReadonlyArray; onItemClick: (event: ItemClickEvent) => unknown; - type: 'media' | 'documents'; theme?: ThemeType; + mediaItems: ReadonlyArray; + + renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element; }; +function getMediaItemKey(mediaItem: GenericMediaItemType): string { + const { message } = mediaItem; + if (mediaItem.type === 'media' || mediaItem.type === 'document') { + return `attachment-${message.id}-${mediaItem.index}`; + } + return `attachment-${message.id}-preview`; +} + +type VerifiedMediaItems = + | { + type: 'media' | 'document'; + entries: ReadonlyArray; + } + | { + type: 'link'; + entries: ReadonlyArray; + }; + +function verifyMediaItems( + mediaItems: ReadonlyArray +): VerifiedMediaItems { + const first = mediaItems.at(0); + strictAssert(first != null, 'AttachmentSection cannot be empty'); + + const { type } = first; + + const result = { + type, + entries: mediaItems.filter(item => item.type === type), + }; + + strictAssert( + result.entries.length === mediaItems.length, + 'Some AttachmentSection items have conflicting types' + ); + + return result as VerifiedMediaItems; +} + export function AttachmentSection({ i18n, header, - type, mediaItems, onItemClick, theme, + + renderLinkPreviewItem, }: Props): JSX.Element { - switch (type) { + const verified = verifyMediaItems(mediaItems); + switch (verified.type) { case 'media': return (

{header}

- {mediaItems.map(mediaItem => { - const { message, index, attachment } = mediaItem; - + {verified.entries.map(mediaItem => { const onClick = (state: AttachmentStatusType['state']) => { - onItemClick({ type, message, attachment, state }); + onItemClick({ mediaItem, state }); }; return (
); - case 'documents': + case 'document': return (

{header}

- {mediaItems.map(mediaItem => { - const { message, index, attachment } = mediaItem; - + {verified.entries.map(mediaItem => { const onClick = (state: AttachmentStatusType['state']) => { - onItemClick({ type, message, attachment, state }); + onItemClick({ mediaItem, state }); }; return ( @@ -85,7 +129,31 @@ export function AttachmentSection({
); + case 'link': + return ( +
+

{header}

+
+ {verified.entries.map(mediaItem => { + const onClick = (state: AttachmentStatusType['state']) => { + onItemClick({ mediaItem, state }); + }; + + return ( + + {renderLinkPreviewItem({ + mediaItem, + onClick, + })} + + ); + })} +
+
+ ); default: - throw missingCaseError(type); + throw missingCaseError(verified); } } diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx index 074d5b34e0..82cb1dcb97 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx @@ -86,7 +86,11 @@ export function DocumentListItem({ value={status.totalDownloaded} /> )} -
+
; export function Default(args: Props): JSX.Element { return ; } + +export function Media(args: Props): JSX.Element { + return ; +} + +export function Documents(args: Props): JSX.Element { + return ; +} + +export function Links(args: Props): JSX.Element { + return ; +} diff --git a/ts/components/conversation/media-gallery/EmptyState.dom.tsx b/ts/components/conversation/media-gallery/EmptyState.dom.tsx index 56829fb377..1d624a12cb 100644 --- a/ts/components/conversation/media-gallery/EmptyState.dom.tsx +++ b/ts/components/conversation/media-gallery/EmptyState.dom.tsx @@ -3,10 +3,51 @@ import React from 'react'; +import type { LocalizerType } from '../../../types/Util.std.js'; +import { tw } from '../../../axo/tw.dom.js'; +import { missingCaseError } from '../../../util/missingCaseError.std.js'; +import { TabViews } from './types/TabViews.std.js'; + export type Props = { - label: string; + i18n: LocalizerType; + tab: TabViews; }; -export function EmptyState({ label }: Props): JSX.Element { - return
{label}
; +export function EmptyState({ i18n, tab }: Props): JSX.Element { + let title: string; + let description: string; + + switch (tab) { + case TabViews.Media: + title = i18n('icu:MediaGallery__EmptyState__title--media'); + description = i18n('icu:MediaGallery__EmptyState__description--media'); + break; + case TabViews.Documents: + title = i18n('icu:MediaGallery__EmptyState__title--documents'); + description = i18n( + 'icu:MediaGallery__EmptyState__description--documents' + ); + break; + case TabViews.Links: + title = i18n('icu:MediaGallery__EmptyState__title--links'); + description = i18n('icu:MediaGallery__EmptyState__description--links'); + break; + default: + throw missingCaseError(tab); + } + + return ( +
+
+

{title}

+

{description}

+
+
+ ); } diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx new file mode 100644 index 0000000000..88d4a6b18d --- /dev/null +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx @@ -0,0 +1,36 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import type { Props } from './LinkPreviewItem.dom.js'; +import { LinkPreviewItem } from './LinkPreviewItem.dom.js'; +import { + createPreparedMediaItems, + createRandomLinks, +} from './utils/mocks.std.js'; + +export default { + title: 'Components/Conversation/MediaGallery/LinkPreviewItem', +} satisfies Meta; + +const { i18n } = window.SignalContext; + +export function Multiple(): JSX.Element { + const items = createPreparedMediaItems(createRandomLinks); + + return ( + <> + {items.map((mediaItem, index) => ( + + ))} + + ); +} diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx new file mode 100644 index 0000000000..6159fc811f --- /dev/null +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx @@ -0,0 +1,121 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback } from 'react'; + +import moment from 'moment'; +import { + getAlt, + getUrl, + defaultBlurHash, +} from '../../../util/Attachment.std.js'; +import type { LinkPreviewMediaItemType } from '../../../types/MediaItem.std.js'; +import type { LocalizerType, ThemeType } from '../../../types/Util.std.js'; +import { tw } from '../../../axo/tw.dom.js'; +import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; +import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js'; +import { ImageOrBlurhash } from '../../ImageOrBlurhash.dom.js'; + +export type DataProps = Readonly<{ + // Required + mediaItem: LinkPreviewMediaItemType; + + // Optional + onClick?: (status: AttachmentStatusType['state']) => void; +}>; + +// Provided by smart layer +export type Props = DataProps & + Readonly<{ + i18n: LocalizerType; + theme?: ThemeType; + authorTitle: string; + }>; + +export function LinkPreviewItem({ + i18n, + theme, + mediaItem, + authorTitle, + onClick, +}: Props): JSX.Element { + const { preview, message } = mediaItem; + + const timestamp = message.receivedAtMs || message.receivedAt; + + const handleClick = useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + onClick?.('ReadyToShow'); + }, + [onClick] + ); + + const url = preview.image == null ? undefined : getUrl(preview.image); + let imageOrPlaceholder: JSX.Element; + if (preview.image != null && url != null) { + const resolvedBlurHash = preview.image.blurHash || defaultBlurHash(theme); + + const { width, height } = preview.image; + + imageOrPlaceholder = ( +
+ +
+ ); + } else { + imageOrPlaceholder = ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx index d9b93d3710..2e3449c799 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx @@ -6,10 +6,12 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { Props } from './MediaGallery.dom.js'; import { MediaGallery } from './MediaGallery.dom.js'; +import { LinkPreviewItem } from './LinkPreviewItem.dom.js'; import { createPreparedMediaItems, createRandomDocuments, createRandomMedia, + createRandomLinks, days, } from './utils/mocks.std.js'; @@ -26,16 +28,28 @@ const createProps = (overrideProps: Partial = {}): Props => ({ documents: overrideProps.documents || [], haveOldestDocument: overrideProps.haveOldestDocument || false, haveOldestMedia: overrideProps.haveOldestMedia || false, + haveOldestLink: overrideProps.haveOldestLink || false, loading: overrideProps.loading || false, media: overrideProps.media || [], + links: overrideProps.links || [], initialLoad: action('initialLoad'), - loadMoreDocuments: action('loadMoreDocuments'), - loadMoreMedia: action('loadMoreMedia'), + loadMore: action('loadMore'), saveAttachment: action('saveAttachment'), showLightbox: action('showLightbox'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), cancelAttachmentDownload: action('cancelAttachmentDownload'), + + renderLinkPreviewItem: ({ mediaItem, onClick }) => { + return ( + + ); + }, }); export function Populated(): JSX.Element { @@ -66,8 +80,9 @@ export function NoMedia(): JSX.Element { export function OneEach(): JSX.Element { const media = createRandomMedia(Date.now(), days(1)).slice(0, 1); const documents = createRandomDocuments(Date.now(), days(1)).slice(0, 1); + const links = createRandomLinks(Date.now(), days(1)).slice(0, 1); - const props = createProps({ documents, media }); + const props = createProps({ documents, media, links }); return ; } diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx index 8e33a856b6..f03480d3f1 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx @@ -1,38 +1,41 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useCallback } from 'react'; import moment from 'moment'; import type { ItemClickEvent } from './types/ItemClickEvent.std.js'; import type { LocalizerType, ThemeType } from '../../../types/Util.std.js'; -import type { MediaItemType } from '../../../types/MediaItem.std.js'; +import type { + LinkPreviewMediaItemType, + MediaItemType, + GenericMediaItemType, +} from '../../../types/MediaItem.std.js'; import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations.preload.js'; import { AttachmentSection } from './AttachmentSection.dom.js'; import { EmptyState } from './EmptyState.dom.js'; +import type { DataProps as LinkPreviewItemPropsType } from './LinkPreviewItem.dom.js'; import { Tabs } from '../../Tabs.dom.js'; +import { TabViews } from './types/TabViews.std.js'; import { groupMediaItemsByDate } from './groupMediaItemsByDate.std.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js'; +import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.js'; import { usePrevious } from '../../../hooks/usePrevious.std.js'; import type { AttachmentType } from '../../../types/Attachment.std.js'; -enum TabViews { - Media = 'Media', - Documents = 'Documents', -} - export type Props = { conversationId: string; - documents: ReadonlyArray; i18n: LocalizerType; haveOldestMedia: boolean; haveOldestDocument: boolean; + haveOldestLink: boolean; loading: boolean; initialLoad: (id: string) => unknown; - loadMoreMedia: (id: string) => unknown; - loadMoreDocuments: (id: string) => unknown; + loadMore: (id: string, type: 'media' | 'documents' | 'links') => unknown; media: ReadonlyArray; + documents: ReadonlyArray; + links: ReadonlyArray; saveAttachment: SaveAttachmentActionCreatorType; kickOffAttachmentDownload: (options: { messageId: string }) => void; cancelAttachmentDownload: (options: { messageId: string }) => void; @@ -41,54 +44,80 @@ export type Props = { messageId: string; }) => void; theme?: ThemeType; + + renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element; }; const MONTH_FORMAT = 'MMMM YYYY'; function MediaSection({ - documents, i18n, loading, - media, + tab, + mediaItems, saveAttachment, kickOffAttachmentDownload, cancelAttachmentDownload, showLightbox, - type, theme, + renderLinkPreviewItem, }: Pick< Props, - | 'documents' | 'i18n' | 'theme' | 'loading' - | 'media' | 'saveAttachment' | 'kickOffAttachmentDownload' | 'cancelAttachmentDownload' | 'showLightbox' -> & { type: 'media' | 'documents' }): JSX.Element { - const mediaItems = type === 'media' ? media : documents; + | 'renderLinkPreviewItem' +> & { + tab: TabViews; + mediaItems: ReadonlyArray; +}): JSX.Element { + const onItemClick = useCallback( + (event: ItemClickEvent) => { + const { state, mediaItem } = event; + const { message } = mediaItem; + if (state === 'Downloading') { + cancelAttachmentDownload({ messageId: message.id }); + return; + } + if (state === 'NeedsDownload') { + kickOffAttachmentDownload({ messageId: message.id }); + return; + } + if (state !== 'ReadyToShow') { + throw missingCaseError(state); + } - if (!mediaItems || mediaItems.length === 0) { + if (mediaItem.type === 'media') { + showLightbox({ + attachment: mediaItem.attachment, + messageId: message.id, + }); + } else if (mediaItem.type === 'document') { + saveAttachment(mediaItem.attachment, message.sentAt); + } else if (mediaItem.type === 'link') { + openLinkInWebBrowser(mediaItem.preview.url); + } else { + throw missingCaseError(mediaItem.type); + } + }, + [ + saveAttachment, + showLightbox, + cancelAttachmentDownload, + kickOffAttachmentDownload, + ] + ); + + if (mediaItems.length === 0) { if (loading) { return
; } - const label = (() => { - switch (type) { - case 'media': - return i18n('icu:mediaEmptyState'); - - case 'documents': - return i18n('icu:documentsEmptyState'); - - default: - throw missingCaseError(type); - } - })(); - - return ; + return ; } const now = Date.now(); @@ -122,43 +151,9 @@ function MediaSection({ header={header} i18n={i18n} theme={theme} - type={type} mediaItems={section.mediaItems} - onItemClick={(event: ItemClickEvent) => { - switch (event.type) { - case 'documents': { - if (event.state === 'ReadyToShow') { - saveAttachment(event.attachment, event.message.sentAt); - } else if (event.state === 'Downloading') { - cancelAttachmentDownload({ messageId: event.message.id }); - } else if (event.state === 'NeedsDownload') { - kickOffAttachmentDownload({ messageId: event.message.id }); - } else { - throw missingCaseError(event.state); - } - break; - } - - case 'media': { - if (event.state === 'ReadyToShow') { - showLightbox({ - attachment: event.attachment, - messageId: event.message.id, - }); - } else if (event.state === 'Downloading') { - cancelAttachmentDownload({ messageId: event.message.id }); - } else if (event.state === 'NeedsDownload') { - kickOffAttachmentDownload({ messageId: event.message.id }); - } else { - throw missingCaseError(event.state); - } - break; - } - - default: - throw new TypeError(`Unknown attachment type: '${event.type}'`); - } - }} + onItemClick={onItemClick} + renderLinkPreviewItem={renderLinkPreviewItem} /> ); }); @@ -168,19 +163,21 @@ function MediaSection({ export function MediaGallery({ conversationId, - documents, haveOldestDocument, haveOldestMedia, + haveOldestLink, i18n, initialLoad, loading, - loadMoreDocuments, - loadMoreMedia, + loadMore, media, + documents, + links, saveAttachment, kickOffAttachmentDownload, cancelAttachmentDownload, showLightbox, + renderLinkPreviewItem, }: Props): JSX.Element { const focusRef = useRef(null); const scrollObserverRef = useRef(null); @@ -196,8 +193,10 @@ export function MediaGallery({ if ( media.length > 0 || documents.length > 0 || + links.length > 0 || haveOldestDocument || - haveOldestMedia + haveOldestMedia || + haveOldestLink ) { return; } @@ -207,9 +206,11 @@ export function MediaGallery({ conversationId, haveOldestDocument, haveOldestMedia, + haveOldestLink, initialLoad, media, documents, + links, ]); const previousLoading = usePrevious(loading, loading); @@ -238,13 +239,18 @@ export function MediaGallery({ if (entry && entry.intersectionRatio > 0) { if (tabViewRef.current === TabViews.Media) { if (!haveOldestMedia) { - loadMoreMedia(conversationId); + loadMore(conversationId, 'media'); + loadingRef.current = true; + } + } else if (tabViewRef.current === TabViews.Documents) { + if (!haveOldestDocument) { + loadMore(conversationId, 'documents'); loadingRef.current = true; } } else { // eslint-disable-next-line no-lonely-if - if (!haveOldestDocument) { - loadMoreDocuments(conversationId); + if (!haveOldestLink) { + loadMore(conversationId, 'links'); loadingRef.current = true; } } @@ -261,9 +267,9 @@ export function MediaGallery({ conversationId, haveOldestDocument, haveOldestMedia, + haveOldestLink, loading, - loadMoreDocuments, - loadMoreMedia, + loadMore, ]); return ( @@ -275,46 +281,45 @@ export function MediaGallery({ id: TabViews.Media, label: i18n('icu:media'), }, + { + id: TabViews.Links, + label: i18n('icu:MediaGallery__tab__links'), + }, { id: TabViews.Documents, - label: i18n('icu:documents'), + label: i18n('icu:MediaGallery__tab__files'), }, ]} > {({ selectedTab }) => { - tabViewRef.current = - selectedTab === TabViews.Media - ? TabViews.Media - : TabViews.Documents; + let mediaItems: ReadonlyArray; + + if (selectedTab === TabViews.Media) { + tabViewRef.current = TabViews.Media; + mediaItems = media; + } else if (selectedTab === TabViews.Documents) { + tabViewRef.current = TabViews.Documents; + mediaItems = documents; + } else if (selectedTab === TabViews.Links) { + tabViewRef.current = TabViews.Links; + mediaItems = links; + } else { + throw new Error(`Unexpected select tab: ${selectedTab}`); + } return (
- {selectedTab === TabViews.Media && ( - - )} - {selectedTab === TabViews.Documents && ( - - )} +
); }} diff --git a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx index f5e39e3d80..aee61b9fe9 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx @@ -44,6 +44,7 @@ type OverridePropsMediaItemType = Partial & { const createMediaItem = ( overrideProps: OverridePropsMediaItemType ): MediaItemType => ({ + type: 'media', index: 0, attachment: overrideProps.attachment || { path: '123', @@ -60,6 +61,10 @@ const createMediaItem = ( receivedAt: Date.now(), receivedAtMs: Date.now(), sentAt: Date.now(), + + // Unused for now + source: undefined, + sourceServiceId: undefined, }, }); diff --git a/ts/components/conversation/media-gallery/groupMediaItemsByDate.std.ts b/ts/components/conversation/media-gallery/groupMediaItemsByDate.std.ts index 7133a4f01a..cd04d8ae23 100644 --- a/ts/components/conversation/media-gallery/groupMediaItemsByDate.std.ts +++ b/ts/components/conversation/media-gallery/groupMediaItemsByDate.std.ts @@ -3,7 +3,7 @@ import moment from 'moment'; import lodash from 'lodash'; -import type { MediaItemType } from '../../../types/MediaItem.std.js'; +import type { GenericMediaItemType } from '../../../types/MediaItem.std.js'; import { missingCaseError } from '../../../util/missingCaseError.std.js'; const { compact, groupBy, sortBy } = lodash; @@ -13,7 +13,7 @@ type YearMonthSectionType = 'yearMonth'; type GenericSection = { type: T; - mediaItems: ReadonlyArray; + mediaItems: ReadonlyArray; }; type StaticSection = GenericSection; type YearMonthSection = GenericSection & { @@ -23,7 +23,7 @@ type YearMonthSection = GenericSection & { export type Section = StaticSection | YearMonthSection; export const groupMediaItemsByDate = ( timestamp: number, - mediaItems: ReadonlyArray + mediaItems: ReadonlyArray ): Array
=> { const referenceDateTime = moment(timestamp); @@ -89,7 +89,7 @@ const toSection = ( type GenericMediaItemWithSection = { order: number; type: T; - mediaItem: MediaItemType; + mediaItem: GenericMediaItemType; }; type MediaItemWithStaticSection = GenericMediaItemWithSection; @@ -108,7 +108,7 @@ const withSection = (referenceDateTime: moment.Moment) => { const thisWeek = moment(referenceDateTime).subtract(7, 'day').startOf('day'); const thisMonth = moment(referenceDateTime).startOf('month'); - return (mediaItem: MediaItemType): MediaItemWithSection => { + return (mediaItem: GenericMediaItemType): MediaItemWithSection => { const { message } = mediaItem; const messageTimestamp = moment(message.receivedAtMs || message.receivedAt); diff --git a/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts b/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts index fdb370d056..e6b91e9e5e 100644 --- a/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts +++ b/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts @@ -1,12 +1,10 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { AttachmentType } from '../../../../types/Attachment.std.js'; +import type { GenericMediaItemType } from '../../../../types/MediaItem.std.js'; import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js'; export type ItemClickEvent = { - message: { id: string; sentAt: number }; - attachment: AttachmentType; - type: 'media' | 'documents'; state: AttachmentStatusType['state']; + mediaItem: GenericMediaItemType; }; diff --git a/ts/components/conversation/media-gallery/types/TabViews.std.ts b/ts/components/conversation/media-gallery/types/TabViews.std.ts new file mode 100644 index 0000000000..c15f1f916d --- /dev/null +++ b/ts/components/conversation/media-gallery/types/TabViews.std.ts @@ -0,0 +1,8 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum TabViews { + Media = 'Media', + Documents = 'Documents', + Links = 'Links', +} diff --git a/ts/components/conversation/media-gallery/utils/mocks.std.ts b/ts/components/conversation/media-gallery/utils/mocks.std.ts index 69f003056d..659645d493 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.std.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.std.ts @@ -3,7 +3,12 @@ import lodash from 'lodash'; import { type MIMEType, IMAGE_JPEG } from '../../../../types/MIME.std.js'; -import type { MediaItemType } from '../../../../types/MediaItem.std.js'; +import type { + MediaItemType, + LinkPreviewMediaItemType, + MediaItemMessageType, +} from '../../../../types/MediaItem.std.js'; +import type { AttachmentForUIType } from '../../../../types/Attachment.std.js'; import { randomBlurHash } from '../../../../util/randomBlurHash.std.js'; import { SignalService } from '../../../../protobuf/index.std.js'; @@ -24,11 +29,7 @@ const contentTypes = { txt: 'application/text', } as unknown as Record; -function createRandomFile( - startTime: number, - timeWindow: number, - fileExtension: string -): MediaItemType { +function createRandomAttachment(fileExtension: string): AttachmentForUIType { const contentType = contentTypes[fileExtension]; const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`; @@ -36,76 +37,131 @@ function createRandomFile( const isPending = !isDownloaded && Math.random() > 0.5; return { - message: { - conversationId: '123', - type: 'incoming', - id: random(Date.now()).toString(), - receivedAt: Math.floor(Math.random() * 10), - receivedAtMs: random(startTime, startTime + timeWindow), - sentAt: Date.now(), - }, - attachment: { - url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined, - path: isDownloaded ? 'abc' : undefined, - pending: isPending, - 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, - }, + url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined, + path: isDownloaded ? 'abc' : undefined, + pending: isPending, + 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, + }; +} + +function createRandomMessage( + startTime: number, + timeWindow: number +): MediaItemMessageType { + return { + conversationId: '123', + type: 'incoming', + id: random(Date.now()).toString(), + receivedAt: Math.floor(Math.random() * 10), + receivedAtMs: random(startTime, startTime + timeWindow), + sentAt: Date.now(), + + // Unused for now + source: undefined, + sourceServiceId: undefined, + }; +} + +function createRandomFile( + type: 'media' | 'document', + startTime: number, + timeWindow: number, + fileExtension: string +): MediaItemType { + return { + type, + message: createRandomMessage(startTime, timeWindow), + attachment: createRandomAttachment(fileExtension), index: 0, }; } +function createRandomLink( + startTime: number, + timeWindow: number +): LinkPreviewMediaItemType { + return { + type: 'link', + message: createRandomMessage(startTime, timeWindow), + preview: { + url: 'https://signal.org/', + domain: 'signal.org', + title: 'Signal', + description: 'description', + image: Math.random() > 0.7 ? createRandomAttachment('png') : undefined, + }, + }; +} + function createRandomFiles( + type: 'media' | 'document', startTime: number, timeWindow: number, fileExtensions: Array ): Array { return range(random(5, 10)).map(() => - createRandomFile(startTime, timeWindow, sample(fileExtensions) as string) + createRandomFile( + type, + startTime, + timeWindow, + sample(fileExtensions) as string + ) ); } export function createRandomDocuments( startTime: number, timeWindow: number ): Array { - return createRandomFiles(startTime, timeWindow, [ + return createRandomFiles('document', startTime, timeWindow, [ 'docx', 'pdf', 'exe', 'txt', ]); } +export function createRandomLinks( + startTime: number, + timeWindow: number +): Array { + return range(random(5, 10)).map(() => + createRandomLink(startTime, timeWindow) + ); +} export function createRandomMedia( startTime: number, timeWindow: number ): Array { - return createRandomFiles(startTime, timeWindow, ['mp4', 'jpg', 'png', 'gif']); + return createRandomFiles('media', startTime, timeWindow, [ + 'mp4', + 'jpg', + 'png', + 'gif', + ]); } -export function createPreparedMediaItems( - fn: typeof createRandomDocuments | typeof createRandomMedia -): Array { +export function createPreparedMediaItems< + Item extends MediaItemType | LinkPreviewMediaItemType, +>(fn: (startTime: number, timeWindow: number) => Array): Array { const now = Date.now(); - return sortBy( + return sortBy( [ ...fn(now, days(1)), ...fn(now - days(1), days(1)), @@ -113,6 +169,6 @@ export function createPreparedMediaItems( ...fn(now - days(30), days(15)), ...fn(now - days(365), days(300)), ], - (item: MediaItemType) => -item.message.receivedAt + item => -item.message.receivedAt ); } diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 6fb68094a2..ccd141569f 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -58,6 +58,7 @@ import type { SyncTaskType } from '../util/syncTasks.preload.js'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup.std.js'; import type { AttachmentType } from '../types/Attachment.std.js'; import type { MediaItemMessageType } from '../types/MediaItem.std.js'; +import type { LinkPreviewType } from '../types/message/LinkPreviews.std.js'; import type { GifType } from '../components/fun/panels/FunPanelGifs.dom.js'; import type { NotificationProfileType } from '../types/NotificationProfile.std.js'; import type { DonationReceipt } from '../types/Donations.std.js'; @@ -590,7 +591,15 @@ export type GetOlderMediaOptionsType = Readonly<{ messageId?: string; receivedAt?: number; sentAt?: number; - type: 'media' | 'files'; + type: 'media' | 'documents'; +}>; + +export type GetOlderLinkPreviewsOptionsType = Readonly<{ + conversationId: string; + limit: number; + messageId?: string; + receivedAt?: number; + sentAt?: number; }>; export type MediaItemDBType = Readonly<{ @@ -599,6 +608,11 @@ export type MediaItemDBType = Readonly<{ message: MediaItemMessageType; }>; +export type LinkPreviewMediaItemDBType = Readonly<{ + preview: LinkPreviewType; + message: MediaItemMessageType; +}>; + export type KyberPreKeyTripleType = Readonly<{ id: PreKeyIdType; signedPreKeyId: number; @@ -829,6 +843,9 @@ type ReadableInterface = { // getOlderMessagesByConversation is JSON on server, full message on Client hasMedia: (conversationId: string) => boolean; getOlderMedia: (options: GetOlderMediaOptionsType) => Array; + getOlderLinkPreviews: ( + options: GetOlderLinkPreviewsOptionsType + ) => Array; getAllStories: (options: { conversationId?: string; sourceServiceId?: ServiceIdString; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index e182776a64..caa6f3ec32 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -136,11 +136,13 @@ import type { GetKnownMessageAttachmentsResultType, GetNearbyMessageFromDeletedSetOptionsType, GetOlderMediaOptionsType, + GetOlderLinkPreviewsOptionsType, GetRecentStoryRepliesOptionsType, GetUnreadByConversationAndMarkReadResultType, IdentityKeyIdType, ItemKeyType, KyberPreKeyTripleType, + LinkPreviewMediaItemDBType, MediaItemDBType, MessageAttachmentsCursorType, MessageCursorType, @@ -454,6 +456,7 @@ export const DataReader: ServerReadableInterface = { hasMedia, getOlderMedia, + getOlderLinkPreviews, getAllNotificationProfiles, getNotificationProfileById, @@ -5192,25 +5195,49 @@ function hasGroupCallHistoryMessage( } 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 db.transaction(() => { + let hasAttachments: boolean; + let hasPreviews: boolean; - return exists === 1; + { + 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/%' + ); + `; + hasAttachments = + db.prepare(query, { pluck: true }).get(params) === 1; + } + + { + const [query, params] = sql` + SELECT EXISTS( + SELECT 1 FROM messages + INDEXED BY messages_hasPreviews + WHERE + conversationId IS ${conversationId} AND + type IN ('incoming', 'outgoing') AND + isViewOnce IS NOT 1 AND + hasPreviews IS 1 + ); + `; + hasPreviews = + db.prepare(query, { pluck: true }).get(params) === 1; + } + + return hasAttachments || hasPreviews; + })(); } function getOlderMedia( @@ -5225,26 +5252,30 @@ function getOlderMedia( }: GetOlderMediaOptionsType ): Array { const timeFilters = { - first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`, - second: sqlFragment`receivedAt < ${maxReceivedAt}`, + first: sqlFragment` + message_attachments.receivedAt = ${maxReceivedAt} + AND + message_attachments.sentAt < ${maxSentAt} + `, + second: sqlFragment`message_attachments.receivedAt < ${maxReceivedAt}`, }; let contentFilter: QueryFragment; if (type === 'media') { // see 'isVisualMedia' in ts/types/Attachment.ts contentFilter = sqlFragment` - contentType LIKE 'image/%' OR - contentType LIKE 'video/%' + message_attachments.contentType LIKE 'image/%' OR + message_attachments.contentType LIKE 'video/%' `; - } else if (type === 'files') { + } else if (type === 'documents') { // see 'isFile' in ts/types/Attachment.ts contentFilter = sqlFragment` - contentType IS NOT NULL AND - contentType IS NOT '' AND - contentType IS NOT 'text/x-signal-plain' AND - contentType NOT LIKE 'audio/%' AND - contentType NOT LIKE 'image/%' AND - contentType NOT LIKE 'video/%' + message_attachments.contentType IS NOT NULL AND + message_attachments.contentType IS NOT '' AND + message_attachments.contentType IS NOT 'text/x-signal-plain' AND + message_attachments.contentType NOT LIKE 'audio/%' AND + message_attachments.contentType NOT LIKE 'image/%' AND + message_attachments.contentType NOT LIKE 'video/%' `; } else { throw missingCaseError(type); @@ -5252,21 +5283,25 @@ function getOlderMedia( const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment` SELECT - * + message_attachments.*, + messages.source AS messageSource, + messages.sourceServiceId AS messageSourceServiceId FROM message_attachments INDEXED BY message_attachments_getOlderMedia + INNER JOIN messages ON + messages.id = message_attachments.messageId WHERE - conversationId IS ${conversationId} AND - editHistoryIndex IS -1 AND - attachmentType IS 'attachment' AND + message_attachments.conversationId IS ${conversationId} AND + message_attachments.editHistoryIndex IS -1 AND + message_attachments.attachmentType IS 'attachment' AND ( ${timeFilter} ) AND (${contentFilter}) 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 + message_attachments.isViewOnce IS NOT 1 AND + message_attachments.messageType IN ('incoming', 'outgoing') AND + (${messageId ?? null} IS NULL OR message_attachments.messageId IS NOT ${messageId ?? null}) + ORDER BY message_attachments.receivedAt DESC, message_attachments.sentAt DESC LIMIT ${limit} `; @@ -5276,16 +5311,30 @@ function getOlderMedia( SELECT second.* FROM (${createQuery(timeFilters.second)}) as second `; - const results: Array = db.prepare(query).all(params); + const results: Array< + MessageAttachmentDBType & { + messageSource: string | null; + messageSourceServiceId: ServiceIdString | null; + } + > = db.prepare(query).all(params); return results.map(attachment => { - const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } = - attachment; + const { + orderInMessage, + messageType, + messageSource, + messageSourceServiceId, + sentAt, + receivedAt, + receivedAtMs, + } = attachment; return { message: { id: attachment.messageId, type: messageType as 'incoming' | 'outgoing', + source: messageSource ?? undefined, + sourceServiceId: messageSourceServiceId ?? undefined, conversationId, receivedAt, receivedAtMs: receivedAtMs ?? undefined, @@ -5297,6 +5346,67 @@ function getOlderMedia( }); } +function getOlderLinkPreviews( + db: ReadableDB, + { + conversationId, + limit, + messageId, + receivedAt: maxReceivedAt = Number.MAX_VALUE, + sentAt: maxSentAt = Number.MAX_VALUE, + }: GetOlderLinkPreviewsOptionsType +): Array { + const timeFilters = { + first: sqlFragment`received_at = ${maxReceivedAt} AND sent_at < ${maxSentAt}`, + second: sqlFragment`received_at < ${maxReceivedAt}`, + }; + + const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment` + SELECT + ${sqlJoin(MESSAGE_COLUMNS_FRAGMENTS)} + FROM messages + INDEXED BY messages_hasPreviews + WHERE + conversationId IS ${conversationId} AND + hasPreviews IS 1 AND + isViewOnce IS NOT 1 AND + type IN ('incoming', 'outgoing') AND + (${messageId ?? null} IS NULL OR id IS NOT ${messageId ?? null}) + AND (${timeFilter}) + ORDER BY received_at DESC, sent_at 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 rows = db.prepare(query).all(params); + + return hydrateMessages(db, rows).map(message => { + strictAssert( + message.preview != null && message.preview.length >= 1, + `getOlderLinkPreviews: got message without previe ${message.id}` + ); + + return { + message: { + id: message.id, + type: message.type as 'incoming' | 'outgoing', + conversationId, + source: message.source, + sourceServiceId: message.sourceServiceId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms ?? undefined, + sentAt: message.sent_at, + }, + preview: message.preview[0], + }; + }); +} + function _markCallHistoryMissed( db: WritableDB, callIds: ReadonlyArray diff --git a/ts/sql/migrations/1550-has-link-preview.std.ts b/ts/sql/migrations/1550-has-link-preview.std.ts new file mode 100644 index 0000000000..5c4bb413c0 --- /dev/null +++ b/ts/sql/migrations/1550-has-link-preview.std.ts @@ -0,0 +1,21 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { WritableDB } from '../Interface.std.js'; + +export default function updateToSchemaVersion1520(db: WritableDB): void { + db.exec(` + ALTER TABLE messages + ADD COLUMN hasPreviews INTEGER NOT NULL + GENERATED ALWAYS AS ( + IFNULL(json_array_length(json, '$.preview'), 0) > 0 + ); + + CREATE INDEX messages_hasPreviews + ON messages (conversationId, received_at DESC, sent_at DESC) + WHERE + hasPreviews IS 1 AND + isViewOnce IS NOT 1 AND + type IN ('incoming', 'outgoing'); + `); +} diff --git a/ts/sql/migrations/index.node.ts b/ts/sql/migrations/index.node.ts index b781a3e089..740602ff9d 100644 --- a/ts/sql/migrations/index.node.ts +++ b/ts/sql/migrations/index.node.ts @@ -130,6 +130,7 @@ import updateToSchemaVersion1510 from './1510-chat-folders-normalize-all-chats.s import updateToSchemaVersion1520 from './1520-poll-votes-unread.std.js'; import updateToSchemaVersion1530 from './1530-update-expiring-index.std.js'; import updateToSchemaVersion1540 from './1540-partial-expiring-index.std.js'; +import updateToSchemaVersion1550 from './1550-has-link-preview.std.js'; import { DataWriter } from '../Server.node.js'; @@ -1618,6 +1619,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray = [ { version: 1520, update: updateToSchemaVersion1520 }, { version: 1530, update: updateToSchemaVersion1530 }, { version: 1540, update: updateToSchemaVersion1540 }, + { version: 1550, update: updateToSchemaVersion1550 }, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/ducks/lightbox.preload.ts b/ts/state/ducks/lightbox.preload.ts index d7e815489b..cb271f9aef 100644 --- a/ts/state/ducks/lightbox.preload.ts +++ b/ts/state/ducks/lightbox.preload.ts @@ -221,6 +221,7 @@ function showLightboxForViewOnceMedia( const media = [ { + type: 'media' as const, attachment: tempAttachment, index: 0, message: { @@ -230,6 +231,8 @@ function showLightboxForViewOnceMedia( receivedAt: message.get('received_at'), receivedAtMs: Number(message.get('received_at_ms')), sentAt: message.get('sent_at'), + source: message.get('source'), + sourceServiceId: message.get('sourceServiceId'), }, }, ]; @@ -332,8 +335,11 @@ function showLightbox(opts: { conversationId: authorId, receivedAt, receivedAtMs: Number(message.get('received_at_ms')), + source: message.get('source'), + sourceServiceId: message.get('sourceServiceId'), sentAt, }, + type: 'media' as const, attachment: getPropsForAttachment( item, 'attachment', diff --git a/ts/state/ducks/mediaGallery.preload.ts b/ts/state/ducks/mediaGallery.preload.ts index a3bf9999b5..bf6f093d08 100644 --- a/ts/state/ducks/mediaGallery.preload.ts +++ b/ts/state/ducks/mediaGallery.preload.ts @@ -7,7 +7,10 @@ import type { ReadonlyDeep } from 'type-fest'; import { createLogger } from '../../logging/log.std.js'; import { DataReader } from '../../sql/Client.preload.js'; -import type { MediaItemDBType } from '../../sql/Interface.std.js'; +import type { + MediaItemDBType, + LinkPreviewMediaItemDBType, +} from '../../sql/Interface.std.js'; import { CONVERSATION_UNLOADED, MESSAGE_CHANGED, @@ -23,8 +26,13 @@ import type { MessageDeletedActionType, MessageExpiredActionType, } from './conversations.preload.js'; -import type { MediaItemType } from '../../types/MediaItem.std.js'; +import type { + MediaItemMessageType, + MediaItemType, + LinkPreviewMediaItemType, +} from '../../types/MediaItem.std.js'; import { isFile, isVisualMedia } from '../../util/Attachment.std.js'; +import { missingCaseError } from '../../util/missingCaseError.std.js'; import type { StateType as RootStateType } from '../reducer.preload.js'; import { getPropsForAttachment } from '../selectors/message.preload.js'; @@ -34,18 +42,19 @@ const log = createLogger('mediaGallery'); export type MediaGalleryStateType = ReadonlyDeep<{ conversationId: string | undefined; - documents: ReadonlyArray; haveOldestDocument: boolean; haveOldestMedia: boolean; + haveOldestLink: boolean; loading: boolean; media: ReadonlyArray; + documents: ReadonlyArray; + links: ReadonlyArray; }>; const FETCH_CHUNK_COUNT = 50; const INITIAL_LOAD = 'mediaGallery/INITIAL_LOAD'; -const LOAD_MORE_MEDIA = 'mediaGallery/LOAD_MORE_MEDIA'; -const LOAD_MORE_DOCUMENTS = 'mediaGallery/LOAD_MORE_DOCUMENTS'; +const LOAD_MORE = 'mediaGallery/LOAD_MORE'; const SET_LOADING = 'mediaGallery/SET_LOADING'; type InitialLoadActionType = ReadonlyDeep<{ @@ -54,20 +63,16 @@ type InitialLoadActionType = ReadonlyDeep<{ conversationId: string; documents: ReadonlyArray; media: ReadonlyArray; + links: ReadonlyArray; }; }>; -type LoadMoreMediaActionType = ReadonlyDeep<{ - type: typeof LOAD_MORE_MEDIA; +type LoadMoreActionType = ReadonlyDeep<{ + type: typeof LOAD_MORE; payload: { conversationId: string; media: ReadonlyArray; - }; -}>; -type LoadMoreDocumentsActionType = ReadonlyDeep<{ - type: typeof LOAD_MORE_DOCUMENTS; - payload: { - conversationId: string; documents: ReadonlyArray; + links: ReadonlyArray; }; }>; type SetLoadingActionType = ReadonlyDeep<{ @@ -80,34 +85,30 @@ type SetLoadingActionType = ReadonlyDeep<{ type MediaGalleryActionType = ReadonlyDeep< | ConversationUnloadedActionType | InitialLoadActionType - | LoadMoreDocumentsActionType - | LoadMoreMediaActionType + | LoadMoreActionType | MessageChangedActionType | MessageDeletedActionType | MessageExpiredActionType | SetLoadingActionType >; -function _sortMedia( - media: ReadonlyArray -): ReadonlyArray { - return orderBy(media, [ +function _sortItems< + Item extends ReadonlyDeep<{ message: MediaItemMessageType }>, +>(items: ReadonlyArray): ReadonlyArray { + return orderBy(items, [ 'message.receivedAt', 'message.sentAt', 'message.index', ]); } -function _sortDocuments( - documents: ReadonlyArray -): ReadonlyArray { - return orderBy(documents, ['message.receivedAt', 'message.sentAt']); -} function _cleanAttachments( + type: 'media' | 'document', rawMedia: ReadonlyArray ): ReadonlyArray { return rawMedia.map(({ message, index, attachment }) => { return { + type, index, attachment: getPropsForAttachment(attachment, 'attachment', message), message, @@ -115,6 +116,24 @@ function _cleanAttachments( }); } +function _cleanLinkPreviews( + rawPreviews: ReadonlyArray +): ReadonlyArray { + return rawPreviews.map(({ message, preview }) => { + return { + type: 'link', + preview: { + ...preview, + image: + preview.image == null + ? undefined + : getPropsForAttachment(preview.image, 'preview', message), + }, + message, + }; + }); +} + function initialLoad( conversationId: string ): ThunkAction< @@ -129,19 +148,26 @@ function initialLoad( payload: { loading: true }, }); - const rawMedia = await DataReader.getOlderMedia({ - conversationId, - limit: FETCH_CHUNK_COUNT, - type: 'media', - }); - const rawDocuments = await DataReader.getOlderMedia({ - conversationId, - limit: FETCH_CHUNK_COUNT, - type: 'files', - }); + const [rawMedia, rawDocuments, rawLinkPreviews] = await Promise.all([ + DataReader.getOlderMedia({ + conversationId, + limit: FETCH_CHUNK_COUNT, + type: 'media', + }), + DataReader.getOlderMedia({ + conversationId, + limit: FETCH_CHUNK_COUNT, + type: 'documents', + }), + DataReader.getOlderLinkPreviews({ + conversationId, + limit: FETCH_CHUNK_COUNT, + }), + ]); - const media = _cleanAttachments(rawMedia); - const documents = _cleanAttachments(rawDocuments); + const media = _cleanAttachments('media', rawMedia); + const documents = _cleanAttachments('document', rawDocuments); + const links = _cleanLinkPreviews(rawLinkPreviews); dispatch({ type: INITIAL_LOAD, @@ -149,32 +175,45 @@ function initialLoad( conversationId, documents, media, + links, }, }); }; } -function loadMoreMedia( - conversationId: string +function loadMore( + conversationId: string, + type: 'media' | 'documents' | 'links' ): ThunkAction< void, RootStateType, unknown, - InitialLoadActionType | LoadMoreMediaActionType | SetLoadingActionType + InitialLoadActionType | LoadMoreActionType | SetLoadingActionType > { return async (dispatch, getState) => { - const { conversationId: previousConversationId, media: previousMedia } = - getState().mediaGallery; + const { mediaGallery } = getState(); + const { conversationId: previousConversationId } = mediaGallery; if (conversationId !== previousConversationId) { - log.warn('loadMoreMedia: conversationId mismatch; calling initialLoad()'); + log.warn('loadMore: conversationId mismatch; calling initialLoad()'); initialLoad(conversationId)(dispatch, getState, {}); return; } - const oldestLoadedMedia = previousMedia[0]; - if (!oldestLoadedMedia) { - log.warn('loadMoreMedia: no previous media; calling initialLoad()'); + let previousItems: ReadonlyArray; + if (type === 'media') { + previousItems = mediaGallery.media; + } else if (type === 'documents') { + previousItems = mediaGallery.documents; + } else if (type === 'links') { + previousItems = mediaGallery.links; + } else { + throw missingCaseError(type); + } + + const oldestLoadedItem = previousItems[0]; + if (!oldestLoadedItem) { + log.warn('loadMore: no previous media; calling initialLoad()'); initialLoad(conversationId)(dispatch, getState, {}); return; } @@ -184,83 +223,46 @@ function loadMoreMedia( payload: { loading: true }, }); - const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message; + const { sentAt, receivedAt, id: messageId } = oldestLoadedItem.message; - const rawMedia = await DataReader.getOlderMedia({ + const sharedOptions = { conversationId, limit: FETCH_CHUNK_COUNT, messageId, receivedAt, sentAt, - type: 'media', - }); + }; - const media = _cleanAttachments(rawMedia); + let media: ReadonlyArray = []; + let documents: ReadonlyArray = []; + let links: ReadonlyArray = []; + if (type === 'media') { + const rawMedia = await DataReader.getOlderMedia({ + ...sharedOptions, + type: 'media', + }); + + media = _cleanAttachments('media', rawMedia); + } else if (type === 'documents') { + const rawDocuments = await DataReader.getOlderMedia({ + ...sharedOptions, + type: 'documents', + }); + documents = _cleanAttachments('document', rawDocuments); + } else if (type === 'links') { + const rawPreviews = await DataReader.getOlderLinkPreviews(sharedOptions); + links = _cleanLinkPreviews(rawPreviews); + } else { + throw missingCaseError(type); + } dispatch({ - type: LOAD_MORE_MEDIA, + type: LOAD_MORE, payload: { conversationId, media, - }, - }); - }; -} - -function loadMoreDocuments( - conversationId: string -): ThunkAction< - void, - RootStateType, - unknown, - InitialLoadActionType | LoadMoreDocumentsActionType | SetLoadingActionType -> { - return async (dispatch, getState) => { - const { - conversationId: previousConversationId, - documents: previousDocuments, - } = getState().mediaGallery; - - if (conversationId !== previousConversationId) { - log.warn( - 'loadMoreDocuments: conversationId mismatch; calling initialLoad()' - ); - initialLoad(conversationId)(dispatch, getState, {}); - return; - } - - const oldestLoadedDocument = previousDocuments[0]; - if (!oldestLoadedDocument) { - log.warn( - 'loadMoreDocuments: no previous documents; calling initialLoad()' - ); - initialLoad(conversationId)(dispatch, getState, {}); - return; - } - - dispatch({ - type: SET_LOADING, - payload: { loading: true }, - }); - - const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message; - - const rawDocuments = await DataReader.getOlderMedia({ - conversationId, - limit: FETCH_CHUNK_COUNT, - messageId, - receivedAt, - sentAt, - type: 'files', - }); - - const documents = _cleanAttachments(rawDocuments); - - dispatch({ - type: LOAD_MORE_DOCUMENTS, - payload: { - conversationId, documents, + links, }, }); }; @@ -268,8 +270,7 @@ function loadMoreDocuments( export const actions = { initialLoad, - loadMoreMedia, - loadMoreDocuments, + loadMore, }; export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< @@ -279,11 +280,13 @@ export const useMediaGalleryActions = (): BoundActionCreatorsMapObject< export function getEmptyState(): MediaGalleryStateType { return { conversationId: undefined, - documents: [], haveOldestDocument: false, haveOldestMedia: false, + haveOldestLink: false, loading: true, media: [], + documents: [], + links: [], }; } @@ -307,15 +310,17 @@ export function reducer( ...state, loading: false, conversationId: payload.conversationId, - media: _sortMedia(payload.media), - documents: _sortDocuments(payload.documents), haveOldestMedia: payload.media.length === 0, haveOldestDocument: payload.documents.length === 0, + haveOldestLink: payload.links.length === 0, + media: _sortItems(payload.media), + documents: _sortItems(payload.documents), + links: _sortItems(payload.links), }; } - if (action.type === LOAD_MORE_MEDIA) { - const { conversationId, media } = action.payload; + if (action.type === LOAD_MORE) { + const { conversationId, media, documents, links } = action.payload; if (state.conversationId !== conversationId) { return state; } @@ -324,21 +329,11 @@ export function reducer( ...state, loading: false, haveOldestMedia: media.length === 0, - media: _sortMedia(media.concat(state.media)), - }; - } - - if (action.type === LOAD_MORE_DOCUMENTS) { - const { conversationId, documents } = action.payload; - if (state.conversationId !== conversationId) { - return state; - } - - return { - ...state, - loading: false, haveOldestDocument: documents.length === 0, - documents: _sortDocuments(documents.concat(state.documents)), + haveOldestLink: links.length === 0, + media: _sortItems(media.concat(state.media)), + documents: _sortItems(documents.concat(state.documents)), + links: _sortItems(links.concat(state.links)), }; } @@ -359,8 +354,12 @@ export function reducer( const documentsWithout = state.documents.filter( item => item.message.id !== message.id ); + const linksWithout = state.links.filter( + item => item.message.id !== message.id + ); const mediaDifference = state.media.length - mediaWithout.length; const documentDifference = state.documents.length - documentsWithout.length; + const linkDifference = state.links.length - linksWithout.length; if (message.deletedForEveryone || message.isErased) { if (mediaDifference > 0 || documentDifference > 0) { @@ -368,6 +367,7 @@ export function reducer( ...state, media: mediaWithout, documents: documentsWithout, + links: linksWithout, }; } return state; @@ -375,6 +375,7 @@ export function reducer( const oldestLoadedMedia = state.media[0]; const oldestLoadedDocument = state.documents[0]; + const oldestLoadedLink = state.links[0]; const messageMediaItems: Array = ( message.attachments ?? [] @@ -385,6 +386,8 @@ export function reducer( message: { id: message.id, type: message.type, + source: message.source, + sourceServiceId: message.sourceServiceId, conversationId: message.conversationId, receivedAt: message.received_at, receivedAtMs: message.received_at_ms, @@ -394,20 +397,48 @@ export function reducer( }); const newMedia = _cleanAttachments( + 'media', messageMediaItems.filter(({ attachment }) => isVisualMedia(attachment)) ); const newDocuments = _cleanAttachments( + 'document', messageMediaItems.filter(({ attachment }) => isFile(attachment)) ); + const newLinks = _cleanLinkPreviews( + message.preview != null && message.preview.length > 0 + ? [ + { + preview: message.preview[0], + message: { + id: message.id, + type: message.type, + source: message.source, + sourceServiceId: message.sourceServiceId, + conversationId: message.conversationId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms, + sentAt: message.sent_at, + }, + }, + ] + : [] + ); - let { documents, haveOldestDocument, haveOldestMedia, media } = state; + let { + documents, + haveOldestDocument, + haveOldestMedia, + media, + haveOldestLink, + links, + } = state; const inMediaTimeRange = !oldestLoadedMedia || (message.received_at >= oldestLoadedMedia.message.receivedAt && message.sent_at >= oldestLoadedMedia.message.sentAt); if (mediaDifference !== media.length && inMediaTimeRange) { - media = _sortMedia(mediaWithout.concat(newMedia)); + media = _sortItems(mediaWithout.concat(newMedia)); } else if (!inMediaTimeRange) { haveOldestMedia = false; } @@ -417,11 +448,21 @@ export function reducer( (message.received_at >= oldestLoadedDocument.message.receivedAt && message.sent_at >= oldestLoadedDocument.message.sentAt); if (documentDifference !== documents.length && inDocumentTimeRange) { - documents = _sortDocuments(documentsWithout.concat(newDocuments)); + documents = _sortItems(documentsWithout.concat(newDocuments)); } else if (!inDocumentTimeRange) { haveOldestDocument = false; } + const inLinkTimeRange = + !oldestLoadedLink || + (message.received_at >= oldestLoadedLink.message.receivedAt && + message.sent_at >= oldestLoadedLink.message.sentAt); + if (linkDifference !== links.length && inLinkTimeRange) { + links = _sortItems(linksWithout.concat(newLinks)); + } else if (!inLinkTimeRange) { + haveOldestLink = false; + } + if ( state.haveOldestDocument !== haveOldestDocument || state.haveOldestMedia !== haveOldestMedia || @@ -433,6 +474,7 @@ export function reducer( documents, haveOldestDocument, haveOldestMedia, + haveOldestLink, media, }; } diff --git a/ts/state/smart/AllMedia.preload.tsx b/ts/state/smart/AllMedia.preload.tsx index e1abbe1df3..a7696ac2b6 100644 --- a/ts/state/smart/AllMedia.preload.tsx +++ b/ts/state/smart/AllMedia.preload.tsx @@ -8,18 +8,32 @@ import { getIntl, getTheme } from '../selectors/user.std.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; import { useLightboxActions } from '../ducks/lightbox.preload.js'; import { useMediaGalleryActions } from '../ducks/mediaGallery.preload.js'; +import { + SmartLinkPreviewItem, + type PropsType as LinkPreviewItemPropsType, +} from './LinkPreviewItem.dom.js'; export type PropsType = { conversationId: string; }; +function renderLinkPreviewItem(props: LinkPreviewItemPropsType): JSX.Element { + return ; +} + export const SmartAllMedia = memo(function SmartAllMedia({ conversationId, }: PropsType) { - const { media, documents, haveOldestDocument, haveOldestMedia, loading } = - useSelector(getMediaGalleryState); - const { initialLoad, loadMoreMedia, loadMoreDocuments } = - useMediaGalleryActions(); + const { + media, + documents, + links, + haveOldestDocument, + haveOldestMedia, + haveOldestLink, + loading, + } = useSelector(getMediaGalleryState); + const { initialLoad, loadMore } = useMediaGalleryActions(); const { saveAttachment, kickOffAttachmentDownload, @@ -34,18 +48,20 @@ export const SmartAllMedia = memo(function SmartAllMedia({ conversationId={conversationId} haveOldestDocument={haveOldestDocument} haveOldestMedia={haveOldestMedia} + haveOldestLink={haveOldestLink} i18n={i18n} theme={theme} initialLoad={initialLoad} loading={loading} - loadMoreMedia={loadMoreMedia} - loadMoreDocuments={loadMoreDocuments} + loadMore={loadMore} media={media} documents={documents} + links={links} showLightbox={showLightbox} kickOffAttachmentDownload={kickOffAttachmentDownload} cancelAttachmentDownload={cancelAttachmentDownload} saveAttachment={saveAttachment} + renderLinkPreviewItem={renderLinkPreviewItem} /> ); }); diff --git a/ts/state/smart/LinkPreviewItem.dom.tsx b/ts/state/smart/LinkPreviewItem.dom.tsx new file mode 100644 index 0000000000..510a5d7a40 --- /dev/null +++ b/ts/state/smart/LinkPreviewItem.dom.tsx @@ -0,0 +1,42 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { memo } from 'react'; +import { useSelector } from 'react-redux'; +import { LinkPreviewItem } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js'; +import { getSafeDomain } from '../../types/LinkPreview.std.js'; +import type { DataProps as PropsType } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js'; +import { getIntl, getTheme } from '../selectors/user.std.js'; +import { getConversationSelector } from '../selectors/conversations.dom.js'; + +export { PropsType }; + +export const SmartLinkPreviewItem = memo(function SmartLinkPreviewItem({ + mediaItem, + onClick, +}: PropsType) { + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + const getConversation = useSelector(getConversationSelector); + + const author = getConversation( + mediaItem.message.sourceServiceId ?? mediaItem.message.source + ); + + const hydratedMediaItem = { + ...mediaItem, + preview: { + ...mediaItem.preview, + domain: getSafeDomain(mediaItem.preview.url), + }, + }; + + return ( + + ); +}); diff --git a/ts/test-node/components/media-gallery/groupMediaItemsByDate.std.ts b/ts/test-node/components/media-gallery/groupMediaItemsByDate.std.ts index 1811834edb..5d2597d888 100644 --- a/ts/test-node/components/media-gallery/groupMediaItemsByDate.std.ts +++ b/ts/test-node/components/media-gallery/groupMediaItemsByDate.std.ts @@ -22,14 +22,17 @@ const testDate = ( const toMediaItem = (id: string, date: Date): MediaItemType => { return { + type: 'media', index: 0, message: { type: 'incoming', conversationId: '1234', - id: 'id', + id, receivedAt: date.getTime(), receivedAtMs: date.getTime(), sentAt: date.getTime(), + source: undefined, + sourceServiceId: undefined, }, attachment: fakeAttachment({ fileName: 'fileName', @@ -63,35 +66,35 @@ describe('groupMediaItemsByDate', () => { assert.strictEqual(actual[0].type, 'today'); assert.strictEqual(actual[0].mediaItems.length, 2, 'today'); - assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1'); - assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2'); + assert.strictEqual(actual[0].mediaItems[0].message.id, 'today-1'); + assert.strictEqual(actual[0].mediaItems[1].message.id, 'today-2'); assert.strictEqual(actual[1].type, 'yesterday'); assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday'); - assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1'); - assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2'); + assert.strictEqual(actual[1].mediaItems[0].message.id, 'yesterday-1'); + assert.strictEqual(actual[1].mediaItems[1].message.id, 'yesterday-2'); assert.strictEqual(actual[2].type, 'thisWeek'); assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek'); - 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[2].mediaItems[0].message.id, 'thisWeek-1'); + assert.strictEqual(actual[2].mediaItems[1].message.id, 'thisWeek-2'); + assert.strictEqual(actual[2].mediaItems[2].message.id, 'thisWeek-3'); + assert.strictEqual(actual[2].mediaItems[3].message.id, 'thisWeek-4'); assert.strictEqual(actual[3].type, 'thisMonth'); assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth'); - assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1'); - assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2'); + assert.strictEqual(actual[3].mediaItems[0].message.id, 'thisMonth-1'); + assert.strictEqual(actual[3].mediaItems[1].message.id, 'thisMonth-2'); assert.strictEqual(actual[4].type, 'yearMonth'); assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024'); - assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1'); - assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2'); + assert.strictEqual(actual[4].mediaItems[0].message.id, 'mar2024-1'); + assert.strictEqual(actual[4].mediaItems[1].message.id, 'mar2024-2'); assert.strictEqual(actual[5].type, 'yearMonth'); assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011'); - assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1'); - assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2'); + assert.strictEqual(actual[5].mediaItems[0].message.id, 'feb2011-1'); + assert.strictEqual(actual[5].mediaItems[1].message.id, 'feb2011-2'); assert.strictEqual(actual.length, 6, 'total sections'); }); diff --git a/ts/types/MediaItem.std.ts b/ts/types/MediaItem.std.ts index eacd4e9fca..e3123f3947 100644 --- a/ts/types/MediaItem.std.ts +++ b/ts/types/MediaItem.std.ts @@ -1,20 +1,33 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { AttachmentForUIType } from './Attachment.std.js'; import type { MessageAttributesType } from '../model-types.d.ts'; +import type { AttachmentForUIType } from './Attachment.std.js'; +import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js'; +import type { ServiceIdString } from './ServiceId.std.js'; export type MediaItemMessageType = Readonly<{ id: string; type: MessageAttributesType['type']; conversationId: string; receivedAt: number; - receivedAtMs?: number; + receivedAtMs: number | undefined; sentAt: number; + source: string | undefined; + sourceServiceId: ServiceIdString | undefined; }>; export type MediaItemType = { + type: 'media' | 'document'; attachment: AttachmentForUIType; index: number; message: MediaItemMessageType; }; + +export type LinkPreviewMediaItemType = Readonly<{ + type: 'link'; + preview: LinkPreviewForUIType; + message: MediaItemMessageType; +}>; + +export type GenericMediaItemType = MediaItemType | LinkPreviewMediaItemType;