Link previews in all media

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2025-11-17 10:58:26 -06:00
committed by GitHub
parent efc01838ca
commit 7f2a93c079
29 changed files with 1122 additions and 422 deletions

View File

@@ -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"

View File

@@ -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;
}
}
}

View File

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

View File

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

View File

@@ -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,
},
},
],

View File

@@ -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 (
<LinkPreviewItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
/>
);
},
onItemClick: action('onItemClick'),
},
} satisfies Meta<Props>;
export function Documents(args: Props) {
const mediaItems = createRandomDocuments(Date.now(), days(1));
return (
<AttachmentSection {...args} type="documents" mediaItems={mediaItems} />
);
return <AttachmentSection {...args} mediaItems={mediaItems} />;
}
export function Media(args: Props) {
const mediaItems = createRandomMedia(Date.now(), days(1));
return <AttachmentSection {...args} type="media" mediaItems={mediaItems} />;
return <AttachmentSection {...args} mediaItems={mediaItems} />;
}

View File

@@ -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<MediaItemType>;
onItemClick: (event: ItemClickEvent) => unknown;
type: 'media' | 'documents';
theme?: ThemeType;
mediaItems: ReadonlyArray<GenericMediaItemType>;
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<MediaItemType>;
}
| {
type: 'link';
entries: ReadonlyArray<LinkPreviewMediaItemType>;
};
function verifyMediaItems(
mediaItems: ReadonlyArray<GenericMediaItemType>
): 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 (
<section className={tw('ps-5')}>
<h2 className={tw('ps-1 pt-4 pb-2 font-semibold')}>{header}</h2>
<div className={tw('flex flex-row flex-wrap gap-1 pb-1')}>
{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 (
<MediaGridItem
key={`${message.id}-${index}`}
key={getMediaItemKey(mediaItem)}
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
@@ -55,7 +101,7 @@ export function AttachmentSection({
</div>
</section>
);
case 'documents':
case 'document':
return (
<section
className={tw(
@@ -66,17 +112,15 @@ export function AttachmentSection({
>
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
<div>
{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 (
<DocumentListItem
i18n={i18n}
key={`${message.id}-${index}`}
key={getMediaItemKey(mediaItem)}
mediaItem={mediaItem}
onClick={onClick}
/>
@@ -85,7 +129,31 @@ export function AttachmentSection({
</div>
</section>
);
case 'link':
return (
<section
className={tw('px-6', 'mb-3 divide-y border-b-border-primary pb-3')}
>
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
<div>
{verified.entries.map(mediaItem => {
const onClick = (state: AttachmentStatusType['state']) => {
onItemClick({ mediaItem, state });
};
return (
<Fragment key={getMediaItemKey(mediaItem)}>
{renderLinkPreviewItem({
mediaItem,
onClick,
})}
</Fragment>
);
})}
</div>
</section>
);
default:
throw missingCaseError(type);
throw missingCaseError(verified);
}
}

View File

@@ -86,7 +86,11 @@ export function DocumentListItem({
value={status.totalDownloaded}
/>
)}
<div className={tw('absolute text-label-primary')}>
<div
className={tw(
'absolute flex items-center justify-center text-label-primary'
)}
>
<AxoSymbol.Icon
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
size={16}

View File

@@ -5,17 +5,36 @@ import * as React from 'react';
import type { Meta } from '@storybook/react';
import type { Props } from './EmptyState.dom.js';
import { EmptyState } from './EmptyState.dom.js';
import { TabViews } from './types/TabViews.std.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/Conversation/MediaGallery/EmptyState',
argTypes: {
label: { control: { type: 'text' } },
tab: {
control: { type: 'select' },
options: [TabViews.Media, TabViews.Documents, TabViews.Links],
},
},
args: {
label: 'placeholder text',
i18n,
tab: TabViews.Media,
},
} satisfies Meta<Props>;
export function Default(args: Props): JSX.Element {
return <EmptyState {...args} />;
}
export function Media(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Media} />;
}
export function Documents(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Documents} />;
}
export function Links(args: Props): JSX.Element {
return <EmptyState {...args} tab={TabViews.Links} />;
}

View File

@@ -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 <div className="module-empty-state">{label}</div>;
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 (
<div
className={tw(
'absolute inset-0',
'flex items-center justify-center',
'pointer-events-none size-full'
)}
>
<div className={tw('text-center')}>
<h3 className={tw('type-title-small')}>{title}</h3>
<p className={tw('type-body-medium')}>{description}</p>
</div>
</div>
);
}

View File

@@ -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<Props>;
const { i18n } = window.SignalContext;
export function Multiple(): JSX.Element {
const items = createPreparedMediaItems(createRandomLinks);
return (
<>
{items.map((mediaItem, index) => (
<LinkPreviewItem
i18n={i18n}
key={index}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={action('onClick')}
/>
))}
</>
);
}

View File

@@ -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 = (
<div className={tw('size-9 overflow-hidden rounded-sm')}>
<ImageOrBlurhash
className={tw('object-cover')}
src={url}
intrinsicWidth={width}
intrinsicHeight={height}
alt={getAlt(preview.image, i18n)}
blurHash={resolvedBlurHash}
/>
</div>
);
} else {
imageOrPlaceholder = (
<div
className={tw(
'flex size-9 items-center justify-center',
'overflow-hidden rounded-sm bg-elevated-background-tertiary'
)}
>
<AxoSymbol.Icon symbol="link" size={20} label={null} />
</div>
);
}
return (
<button
className={tw('flex w-full flex-row gap-3 py-2')}
type="button"
onClick={handleClick}
aria-label={i18n('icu:LinkPreviewItem__alt')}
>
<div className={tw('shrink-0')}>{imageOrPlaceholder}</div>
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate type-body-large')}>
{preview.title ?? ''}
</h3>
<div
className={tw(
'truncate type-body-small leading-4 text-label-secondary'
)}
>
<a
className={tw('type-body-medium text-label-secondary underline')}
href={preview.url}
rel="noreferrer"
target="_blank"
>
{preview.url}
</a>
</div>
<div className={tw('truncate type-body-small text-label-secondary')}>
{authorTitle} · {preview.domain}
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
</button>
);
}

View File

@@ -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> = {}): 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 (
<LinkPreviewItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
/>
);
},
});
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 <MediaGallery {...props} />;
}

View File

@@ -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<MediaItemType>;
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<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
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<GenericMediaItemType>;
}): 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 <div />;
}
const label = (() => {
switch (type) {
case 'media':
return i18n('icu:mediaEmptyState');
case 'documents':
return i18n('icu:documentsEmptyState');
default:
throw missingCaseError(type);
}
})();
return <EmptyState data-test="EmptyState" label={label} />;
return <EmptyState i18n={i18n} tab={tab} />;
}
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<HTMLDivElement | null>(null);
const scrollObserverRef = useRef<HTMLDivElement | null>(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<GenericMediaItemType>;
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 (
<div className="module-media-gallery__content">
{selectedTab === TabViews.Media && (
<MediaSection
documents={documents}
i18n={i18n}
loading={loading}
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
type="media"
/>
)}
{selectedTab === TabViews.Documents && (
<MediaSection
documents={documents}
i18n={i18n}
loading={loading}
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
type="documents"
/>
)}
<MediaSection
i18n={i18n}
loading={loading}
tab={tabViewRef.current}
mediaItems={mediaItems}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
renderLinkPreviewItem={renderLinkPreviewItem}
/>
</div>
);
}}

View File

@@ -44,6 +44,7 @@ type OverridePropsMediaItemType = Partial<MediaItemType> & {
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,
},
});

View File

@@ -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<T> = {
type: T;
mediaItems: ReadonlyArray<MediaItemType>;
mediaItems: ReadonlyArray<GenericMediaItemType>;
};
type StaticSection = GenericSection<StaticSectionType>;
type YearMonthSection = GenericSection<YearMonthSectionType> & {
@@ -23,7 +23,7 @@ type YearMonthSection = GenericSection<YearMonthSectionType> & {
export type Section = StaticSection | YearMonthSection;
export const groupMediaItemsByDate = (
timestamp: number,
mediaItems: ReadonlyArray<MediaItemType>
mediaItems: ReadonlyArray<GenericMediaItemType>
): Array<Section> => {
const referenceDateTime = moment(timestamp);
@@ -89,7 +89,7 @@ const toSection = (
type GenericMediaItemWithSection<T> = {
order: number;
type: T;
mediaItem: MediaItemType;
mediaItem: GenericMediaItemType;
};
type MediaItemWithStaticSection =
GenericMediaItemWithSection<StaticSectionType>;
@@ -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);

View File

@@ -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;
};

View File

@@ -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',
}

View File

@@ -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<string, MIMEType>;
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<string>
): Array<MediaItemType> {
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<MediaItemType> {
return createRandomFiles(startTime, timeWindow, [
return createRandomFiles('document', startTime, timeWindow, [
'docx',
'pdf',
'exe',
'txt',
]);
}
export function createRandomLinks(
startTime: number,
timeWindow: number
): Array<LinkPreviewMediaItemType> {
return range(random(5, 10)).map(() =>
createRandomLink(startTime, timeWindow)
);
}
export function createRandomMedia(
startTime: number,
timeWindow: number
): Array<MediaItemType> {
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<MediaItemType> {
export function createPreparedMediaItems<
Item extends MediaItemType | LinkPreviewMediaItemType,
>(fn: (startTime: number, timeWindow: number) => Array<Item>): Array<Item> {
const now = Date.now();
return sortBy(
return sortBy<Item>(
[
...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
);
}

View File

@@ -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<MediaItemDBType>;
getOlderLinkPreviews: (
options: GetOlderLinkPreviewsOptionsType
) => Array<LinkPreviewMediaItemDBType>;
getAllStories: (options: {
conversationId?: string;
sourceServiceId?: ServiceIdString;

View File

@@ -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<number>(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<number>(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<number>(params) === 1;
}
return hasAttachments || hasPreviews;
})();
}
function getOlderMedia(
@@ -5225,26 +5252,30 @@ function getOlderMedia(
}: GetOlderMediaOptionsType
): Array<MediaItemDBType> {
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<MessageAttachmentDBType> = 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<LinkPreviewMediaItemDBType> {
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<MessageTypeUnhydrated>(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<string>

View File

@@ -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');
`);
}

View File

@@ -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<SchemaUpdateType> = [
{ version: 1520, update: updateToSchemaVersion1520 },
{ version: 1530, update: updateToSchemaVersion1530 },
{ version: 1540, update: updateToSchemaVersion1540 },
{ version: 1550, update: updateToSchemaVersion1550 },
];
export class DBVersionFromFutureError extends Error {

View File

@@ -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',

View File

@@ -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<MediaItemType>;
haveOldestDocument: boolean;
haveOldestMedia: boolean;
haveOldestLink: boolean;
loading: boolean;
media: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
}>;
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<MediaItemType>;
media: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
};
}>;
type LoadMoreMediaActionType = ReadonlyDeep<{
type: typeof LOAD_MORE_MEDIA;
type LoadMoreActionType = ReadonlyDeep<{
type: typeof LOAD_MORE;
payload: {
conversationId: string;
media: ReadonlyArray<MediaItemType>;
};
}>;
type LoadMoreDocumentsActionType = ReadonlyDeep<{
type: typeof LOAD_MORE_DOCUMENTS;
payload: {
conversationId: string;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
};
}>;
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<MediaItemType>
): ReadonlyArray<MediaItemType> {
return orderBy(media, [
function _sortItems<
Item extends ReadonlyDeep<{ message: MediaItemMessageType }>,
>(items: ReadonlyArray<Item>): ReadonlyArray<Item> {
return orderBy(items, [
'message.receivedAt',
'message.sentAt',
'message.index',
]);
}
function _sortDocuments(
documents: ReadonlyArray<MediaItemType>
): ReadonlyArray<MediaItemType> {
return orderBy(documents, ['message.receivedAt', 'message.sentAt']);
}
function _cleanAttachments(
type: 'media' | 'document',
rawMedia: ReadonlyArray<MediaItemDBType>
): ReadonlyArray<MediaItemType> {
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<LinkPreviewMediaItemDBType>
): ReadonlyArray<LinkPreviewMediaItemType> {
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<MediaItemType | LinkPreviewMediaItemType>;
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<MediaItemType> = [];
let documents: ReadonlyArray<MediaItemType> = [];
let links: ReadonlyArray<LinkPreviewMediaItemType> = [];
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<MediaItemDBType> = (
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,
};
}

View File

@@ -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 <SmartLinkPreviewItem {...props} />;
}
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}
/>
);
});

View File

@@ -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 (
<LinkPreviewItem
i18n={i18n}
theme={theme}
authorTitle={author.title}
mediaItem={hydratedMediaItem}
onClick={onClick}
/>
);
});

View File

@@ -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');
});

View File

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