- {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;