Audio tab in media gallery

This commit is contained in:
Fedor Indutny
2025-11-18 14:40:01 -08:00
committed by GitHub
parent 3592bbf9f2
commit 5745cc0083
27 changed files with 946 additions and 392 deletions

View File

@@ -6716,10 +6716,22 @@
"messageformat": "Add group description...",
"description": "Placeholder text in the details header for those that can edit the group description"
},
"icu:AudioListItem__subtitle--voice-message": {
"messageformat": "Voice Message",
"description": "Subtitle of the voice message list item in the audio tab of media gallery"
},
"icu:AudioListItem__subtitle--audio": {
"messageformat": "Audio",
"description": "Subtitle of the audio list item in the audio tab of media gallery"
},
"icu:LinkPreviewItem__alt": {
"messageformat": "Open the link in a browser",
"description": "Alt text for the link preview item button"
},
"icu:MediaGallery__tab__audio": {
"messageformat": "Audio",
"description": "Header of the links pane in the media gallery, showing audio"
},
"icu:MediaGallery__tab__files": {
"messageformat": "Files",
"description": "Header of the links pane in the media gallery, showing files"
@@ -6736,6 +6748,14 @@
"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--audio": {
"messageformat": "No Audio",
"description": "Title of the empty state view of media gallery for audio tab"
},
"icu:MediaGallery__EmptyState__description--audio": {
"messageformat": "Voice Messages and Audio Files that you send and receive will appear here",
"description": "Description of the empty state view of media gallery for audio tab"
},
"icu:MediaGallery__EmptyState__title--links": {
"messageformat": "No Links",
"description": "Title of the empty state view of media gallery for links tab"

View File

@@ -2476,7 +2476,6 @@ button.ConversationDetails__action-button {
}
.module-media-gallery__content {
display: flex;
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
@@ -2487,7 +2486,6 @@ button.ConversationDetails__action-button {
position: absolute;
bottom: 0;
height: 30px;
width: 100%;
&::after {
content: '';

View File

@@ -39,7 +39,7 @@ const computeQueue = new PQueue({
concurrency: MAX_PARALLEL_COMPUTE,
});
async function getAudioDuration(buffer: ArrayBuffer): Promise<number> {
export async function getAudioDuration(buffer: ArrayBuffer): Promise<number> {
const blob = new Blob([buffer]);
const blobURL = URL.createObjectURL(blob);
const audio = new Audio();

View File

@@ -8,14 +8,14 @@ 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,
createRandomLinks,
createRandomAudio,
days,
} from './utils/mocks.std.js';
const { i18n } = window.SignalContext;
import { MediaItem } from './utils/storybook.dom.js';
export default {
title: 'Components/Conversation/MediaGallery/AttachmentSection',
@@ -24,19 +24,9 @@ export default {
header: { control: { type: 'text' } },
},
args: {
i18n,
header: 'Today',
mediaItems: [],
renderLinkPreviewItem: ({ mediaItem, onClick }) => {
return (
<LinkPreviewItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
/>
);
},
renderMediaItem: props => <MediaItem {...props} />,
onItemClick: action('onItemClick'),
},
} satisfies Meta<Props>;
@@ -50,3 +40,13 @@ export function Media(args: Props) {
const mediaItems = createRandomMedia(Date.now(), days(1));
return <AttachmentSection {...args} mediaItems={mediaItems} />;
}
export function Audio(args: Props) {
const mediaItems = createRandomAudio(Date.now(), days(1));
return <AttachmentSection {...args} mediaItems={mediaItems} />;
}
export function Links(args: Props) {
const mediaItems = createRandomLinks(Date.now(), days(1));
return <AttachmentSection {...args} mediaItems={mediaItems} />;
}

View File

@@ -4,28 +4,24 @@
import React, { Fragment } from 'react';
import type { ItemClickEvent } from './types/ItemClickEvent.std.js';
import type { LocalizerType, ThemeType } from '../../../types/Util.std.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;
onItemClick: (event: ItemClickEvent) => unknown;
theme?: ThemeType;
mediaItems: ReadonlyArray<GenericMediaItemType>;
renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element;
renderMediaItem: (props: {
onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType;
}) => JSX.Element;
};
function getMediaItemKey(mediaItem: GenericMediaItemType): string {
@@ -38,7 +34,7 @@ function getMediaItemKey(mediaItem: GenericMediaItemType): string {
type VerifiedMediaItems =
| {
type: 'media' | 'document';
type: 'media' | 'audio' | 'document';
entries: ReadonlyArray<MediaItemType>;
}
| {
@@ -68,13 +64,11 @@ function verifyMediaItems(
}
export function AttachmentSection({
i18n,
header,
mediaItems,
onItemClick,
theme,
renderLinkPreviewItem,
renderMediaItem,
}: Props): JSX.Element {
const verified = verifyMediaItems(mediaItems);
switch (verified.type) {
@@ -84,68 +78,31 @@ export function AttachmentSection({
<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')}>
{verified.entries.map(mediaItem => {
const onClick = (state: AttachmentStatusType['state']) => {
onItemClick({ mediaItem, state });
};
return (
<MediaGridItem
key={getMediaItemKey(mediaItem)}
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
theme={theme}
/>
<Fragment key={getMediaItemKey(mediaItem)}>
{renderMediaItem({
mediaItem,
onItemClick,
})}
</Fragment>
);
})}
</div>
</section>
);
case 'document':
return (
<section
className={tw(
'px-6',
'mb-3 border-b border-b-border-primary pb-3',
'last:mb-0 last:border-b-0 last:pb-0'
)}
>
<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 (
<DocumentListItem
i18n={i18n}
key={getMediaItemKey(mediaItem)}
mediaItem={mediaItem}
onClick={onClick}
/>
);
})}
</div>
</section>
);
case 'audio':
case 'link':
return (
<section
className={tw('px-6', 'mb-3 divide-y border-b-border-primary pb-3')}
>
<section className={tw('mb-3 border-b-border-primary px-6 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({
{renderMediaItem({
mediaItem,
onClick,
onItemClick,
})}
</Fragment>
);

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 './AudioListItem.dom.js';
import { AudioListItem } from './AudioListItem.dom.js';
import {
createPreparedMediaItems,
createRandomAudio,
} from './utils/mocks.std.js';
export default {
title: 'Components/Conversation/MediaGallery/AudioListItem',
} satisfies Meta<Props>;
const { i18n } = window.SignalContext;
export function Multiple(): JSX.Element {
const items = createPreparedMediaItems(createRandomAudio);
return (
<>
{items.map((mediaItem, index) => (
<AudioListItem
i18n={i18n}
key={index}
mediaItem={mediaItem}
authorTitle="Alice"
onClick={action('onClick')}
/>
))}
</>
);
}

View File

@@ -0,0 +1,107 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { noop } from 'lodash';
import { tw } from '../../../axo/tw.dom.js';
import { formatFileSize } from '../../../util/formatFileSize.std.js';
import { durationToPlaybackText } from '../../../util/durationToPlaybackText.std.js';
import type { MediaItemType } from '../../../types/MediaItem.std.js';
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
import { type AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js';
import { useComputePeaks } from '../../../hooks/useComputePeaks.dom.js';
import { ListItem } from './ListItem.dom.js';
const BAR_COUNT = 7;
const MAX_PEAK_HEIGHT = 22;
const MIN_PEAK_HEIGHT = 2;
export type DataProps = Readonly<{
mediaItem: MediaItemType;
onClick: (status: AttachmentStatusType['state']) => void;
}>;
// Provided by smart layer
export type Props = DataProps &
Readonly<{
i18n: LocalizerType;
theme?: ThemeType;
authorTitle: string;
}>;
export function AudioListItem({
i18n,
mediaItem,
authorTitle,
onClick,
}: Props): JSX.Element {
const { attachment } = mediaItem;
const { fileName, size: fileSize, url } = attachment;
const { duration, hasPeaks, peaks } = useComputePeaks({
audioUrl: url,
activeDuration: attachment?.duration,
barCount: BAR_COUNT,
onCorrupted: noop,
});
const subtitle = new Array<string>();
if (typeof fileSize === 'number') {
subtitle.push(formatFileSize(fileSize));
}
if (attachment.isVoiceMessage) {
subtitle.push(i18n('icu:AudioListItem__subtitle--voice-message'));
} else {
subtitle.push(i18n('icu:AudioListItem__subtitle--audio'));
}
subtitle.push(durationToPlaybackText(duration));
const thumbnail = (
<div
className={tw(
'flex items-center justify-center gap-0.5',
'bg-elevated-background-tertiary',
'size-9 rounded-sm'
)}
>
{peaks.map((peak, index) => {
let height: number;
if (hasPeaks) {
height = Math.max(MIN_PEAK_HEIGHT, peak * MAX_PEAK_HEIGHT);
} else {
// Intentionally zero when processing or not downloaded
height = 0;
}
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={index}
className={tw(
'rounded bg-label-placeholder p-px',
'transition-[height] duration-250'
)}
style={{ height: `${height}px` }}
/>
);
})}
</div>
);
return (
<ListItem
i18n={i18n}
mediaItem={mediaItem}
thumbnail={thumbnail}
title={fileName == null ? authorTitle : `${fileName} · ${authorTitle}`}
subtitle={subtitle.join(' · ')}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
/>
);
}

View File

@@ -1,29 +1,23 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React from 'react';
import moment from 'moment';
import { formatFileSize } from '../../../util/formatFileSize.std.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
import type { MediaItemType } from '../../../types/MediaItem.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { SpinnerV2 } from '../../SpinnerV2.dom.js';
import { tw } from '../../../axo/tw.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import { FileThumbnail } from '../../FileThumbnail.dom.js';
import {
useAttachmentStatus,
type AttachmentStatusType,
} from '../../../hooks/useAttachmentStatus.std.js';
import { ListItem } from './ListItem.dom.js';
export type Props = {
i18n: LocalizerType;
// Required
mediaItem: MediaItemType;
// Optional
onClick?: (status: AttachmentStatusType['state']) => void;
onClick: (status: AttachmentStatusType['state']) => void;
};
export function DocumentListItem({
@@ -31,36 +25,13 @@ export function DocumentListItem({
mediaItem,
onClick,
}: Props): JSX.Element {
const { attachment, message } = mediaItem;
const { attachment } = mediaItem;
const { fileName, size: fileSize } = attachment;
const timestamp = message.receivedAtMs || message.receivedAt;
let label: string;
const status = useAttachmentStatus(attachment);
const handleClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
onClick?.(status.state);
},
[onClick, status.state]
);
if (status.state === 'NeedsDownload') {
label = i18n('icu:downloadAttachment');
} else if (status.state === 'Downloading') {
label = i18n('icu:cancelDownload');
} else if (status.state === 'ReadyToShow') {
label = i18n('icu:startDownload');
} else {
throw missingCaseError(status);
}
let glyph: JSX.Element | undefined;
let button: JSX.Element | undefined;
if (status.state !== 'ReadyToShow') {
glyph = (
<>
@@ -68,60 +39,24 @@ export function DocumentListItem({
&nbsp;
</>
);
button = (
<div
className={tw(
'relative -ms-1 size-7 shrink-0 rounded-full bg-fill-secondary',
'flex items-center justify-center'
)}
>
{status.state === 'Downloading' && (
<SpinnerV2
variant="no-background-incoming"
size={28}
strokeWidth={1}
marginRatio={1}
min={0}
max={status.size}
value={status.totalDownloaded}
/>
)}
<div
className={tw(
'absolute flex items-center justify-center text-label-primary'
)}
>
<AxoSymbol.Icon
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
size={16}
label={null}
/>
</div>
</div>
);
}
const subtitle = (
<>
{glyph}
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
</>
);
return (
<button
className={tw('flex w-full flex-row items-center gap-3 py-2')}
type="button"
onClick={handleClick}
aria-label={label}
>
<div className={tw('shrink-0')}>
<FileThumbnail {...attachment} />
</div>
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate')}>{fileName}</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{glyph}
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
{button}
</button>
<ListItem
i18n={i18n}
mediaItem={mediaItem}
thumbnail={<FileThumbnail {...attachment} />}
title={fileName}
subtitle={subtitle}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
/>
);
}

View File

@@ -22,6 +22,10 @@ export function EmptyState({ i18n, tab }: Props): JSX.Element {
title = i18n('icu:MediaGallery__EmptyState__title--media');
description = i18n('icu:MediaGallery__EmptyState__description--media');
break;
case TabViews.Audio:
title = i18n('icu:MediaGallery__EmptyState__title--audio');
description = i18n('icu:MediaGallery__EmptyState__description--audio');
break;
case TabViews.Documents:
title = i18n('icu:MediaGallery__EmptyState__title--documents');
description = i18n(

View File

@@ -1,9 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React from 'react';
import moment from 'moment';
import {
getAlt,
getUrl,
@@ -15,13 +14,11 @@ 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';
import { ListItem } from './ListItem.dom.js';
export type DataProps = Readonly<{
// Required
mediaItem: LinkPreviewMediaItemType;
// Optional
onClick?: (status: AttachmentStatusType['state']) => void;
onClick: (status: AttachmentStatusType['state']) => void;
}>;
// Provided by smart layer
@@ -39,17 +36,7 @@ export function LinkPreviewItem({
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 { preview } = mediaItem;
const url = preview.image == null ? undefined : getUrl(preview.image);
let imageOrPlaceholder: JSX.Element;
@@ -83,39 +70,30 @@ export function LinkPreviewItem({
);
}
const subtitle = (
<>
<a
className={tw('type-body-medium text-label-secondary underline')}
href={preview.url}
rel="noreferrer"
target="_blank"
>
{preview.url}
</a>
<br />
{authorTitle} · {preview.domain}
</>
);
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>
<ListItem
i18n={i18n}
mediaItem={mediaItem}
thumbnail={imageOrPlaceholder}
title={preview.title ?? ''}
subtitle={subtitle}
readyLabel={i18n('icu:LinkPreviewItem__alt')}
onClick={onClick}
/>
);
}

View File

@@ -0,0 +1,133 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import moment from 'moment';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
import type { GenericMediaItemType } from '../../../types/MediaItem.std.js';
import type { AttachmentForUIType } from '../../../types/Attachment.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { SpinnerV2 } from '../../SpinnerV2.dom.js';
import { tw } from '../../../axo/tw.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import {
useAttachmentStatus,
type AttachmentStatusType,
} from '../../../hooks/useAttachmentStatus.std.js';
export type Props = {
i18n: LocalizerType;
mediaItem: GenericMediaItemType;
thumbnail: React.ReactNode;
title: React.ReactNode;
subtitle: React.ReactNode;
readyLabel: string;
onClick: (status: AttachmentStatusType['state']) => void;
};
export function ListItem({
i18n,
mediaItem,
thumbnail,
title,
subtitle,
readyLabel,
onClick,
}: Props): JSX.Element {
const { message } = mediaItem;
let attachment: AttachmentForUIType | undefined;
if (mediaItem.type === 'link') {
attachment = mediaItem.preview.image;
} else {
({ attachment } = mediaItem);
}
const timestamp = message.receivedAtMs || message.receivedAt;
let label: string;
const status = useAttachmentStatus(attachment);
const handleClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
onClick?.(status?.state || 'ReadyToShow');
},
[onClick, status?.state]
);
if (status == null || status.state === 'ReadyToShow') {
label = readyLabel;
} else if (status.state === 'NeedsDownload') {
label = i18n('icu:downloadAttachment');
} else if (status.state === 'Downloading') {
label = i18n('icu:cancelDownload');
} else {
throw missingCaseError(status);
}
let button: JSX.Element | undefined;
if (
status != null &&
status.state !== 'ReadyToShow' &&
mediaItem.type !== 'link'
) {
button = (
<div
className={tw(
'relative -ms-1 size-7 shrink-0 rounded-full bg-fill-secondary',
'flex items-center justify-center'
)}
>
{status.state === 'Downloading' && (
<SpinnerV2
variant="no-background-incoming"
size={28}
strokeWidth={1}
marginRatio={1}
min={0}
max={status.size}
value={status.totalDownloaded}
/>
)}
<div
className={tw(
'absolute flex items-center justify-center text-label-primary'
)}
>
<AxoSymbol.Icon
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
size={16}
label={null}
/>
</div>
</div>
);
}
return (
<button
className={tw(
'flex w-full flex-row gap-3 py-2',
mediaItem.type === 'link' ? undefined : 'items-center'
)}
type="button"
onClick={handleClick}
aria-label={label}
>
<div className={tw('shrink-0')}>{thumbnail}</div>
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate')}>{title}</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{subtitle}
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
{button}
</button>
);
}

View File

@@ -6,14 +6,15 @@ 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,
createRandomAudio,
createRandomLinks,
days,
} from './utils/mocks.std.js';
import { MediaItem } from './utils/storybook.dom.js';
const { i18n } = window.SignalContext;
@@ -25,31 +26,27 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
conversationId: '123',
documents: overrideProps.documents || [],
haveOldestDocument: overrideProps.haveOldestDocument || false,
haveOldestMedia: overrideProps.haveOldestMedia || false,
haveOldestAudio: overrideProps.haveOldestAudio || false,
haveOldestLink: overrideProps.haveOldestLink || false,
haveOldestDocument: overrideProps.haveOldestDocument || false,
loading: overrideProps.loading || false,
media: overrideProps.media || [],
audio: overrideProps.audio || [],
links: overrideProps.links || [],
documents: overrideProps.documents || [],
initialLoad: action('initialLoad'),
loadMore: action('loadMore'),
saveAttachment: action('saveAttachment'),
playAudio: action('playAudio'),
showLightbox: action('showLightbox'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
renderLinkPreviewItem: ({ mediaItem, onClick }) => {
return (
<LinkPreviewItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
/>
);
},
renderMediaItem: props => <MediaItem {...props} />,
renderMiniPlayer: () => <div />,
});
export function Populated(): JSX.Element {
@@ -79,10 +76,11 @@ export function NoMedia(): JSX.Element {
export function OneEach(): JSX.Element {
const media = createRandomMedia(Date.now(), days(1)).slice(0, 1);
const audio = createRandomAudio(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, links });
const props = createProps({ documents, audio, media, links });
return <MediaGallery {...props} />;
}

View File

@@ -6,7 +6,7 @@ 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 { LocalizerType } from '../../../types/Util.std.js';
import type {
LinkPreviewMediaItemType,
MediaItemType,
@@ -15,37 +15,46 @@ import type {
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';
import type { AttachmentForUIType } from '../../../types/Attachment.std.js';
import { tw } from '../../../axo/tw.dom.js';
export type Props = {
conversationId: string;
i18n: LocalizerType;
haveOldestMedia: boolean;
haveOldestDocument: boolean;
haveOldestAudio: boolean;
haveOldestLink: boolean;
haveOldestDocument: boolean;
loading: boolean;
initialLoad: (id: string) => unknown;
loadMore: (id: string, type: 'media' | 'documents' | 'links') => unknown;
loadMore: (
id: string,
type: 'media' | 'audio' | 'documents' | 'links'
) => unknown;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
saveAttachment: SaveAttachmentActionCreatorType;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
playAudio: (attachment: MediaItemType) => void;
showLightbox: (options: {
attachment: AttachmentType;
attachment: AttachmentForUIType;
messageId: string;
}) => void;
theme?: ThemeType;
renderLinkPreviewItem: (props: LinkPreviewItemPropsType) => JSX.Element;
renderMiniPlayer: () => JSX.Element;
renderMediaItem: (props: {
onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType;
}) => JSX.Element;
};
const MONTH_FORMAT = 'MMMM YYYY';
@@ -59,18 +68,18 @@ function MediaSection({
kickOffAttachmentDownload,
cancelAttachmentDownload,
showLightbox,
theme,
renderLinkPreviewItem,
playAudio,
renderMediaItem,
}: Pick<
Props,
| 'i18n'
| 'theme'
| 'loading'
| 'saveAttachment'
| 'kickOffAttachmentDownload'
| 'cancelAttachmentDownload'
| 'showLightbox'
| 'renderLinkPreviewItem'
| 'playAudio'
| 'renderMediaItem'
> & {
tab: TabViews;
mediaItems: ReadonlyArray<GenericMediaItemType>;
@@ -100,6 +109,8 @@ function MediaSection({
saveAttachment(mediaItem.attachment, message.sentAt);
} else if (mediaItem.type === 'link') {
openLinkInWebBrowser(mediaItem.preview.url);
} else if (mediaItem.type === 'audio') {
playAudio(mediaItem);
} else {
throw missingCaseError(mediaItem.type);
}
@@ -109,6 +120,7 @@ function MediaSection({
showLightbox,
cancelAttachmentDownload,
kickOffAttachmentDownload,
playAudio,
]
);
@@ -149,35 +161,39 @@ function MediaSection({
<AttachmentSection
key={header}
header={header}
i18n={i18n}
theme={theme}
mediaItems={section.mediaItems}
onItemClick={onItemClick}
renderLinkPreviewItem={renderLinkPreviewItem}
renderMediaItem={renderMediaItem}
/>
);
});
return <div className="module-media-gallery__sections">{sections}</div>;
return (
<div className={tw('flex min-w-0 grow flex-col divide-y')}>{sections}</div>
);
}
export function MediaGallery({
conversationId,
haveOldestDocument,
haveOldestMedia,
haveOldestAudio,
haveOldestLink,
haveOldestDocument,
i18n,
initialLoad,
loading,
loadMore,
media,
documents,
audio,
links,
documents,
saveAttachment,
kickOffAttachmentDownload,
cancelAttachmentDownload,
playAudio,
showLightbox,
renderLinkPreviewItem,
renderMediaItem,
renderMiniPlayer,
}: Props): JSX.Element {
const focusRef = useRef<HTMLDivElement | null>(null);
const scrollObserverRef = useRef<HTMLDivElement | null>(null);
@@ -192,11 +208,13 @@ export function MediaGallery({
useEffect(() => {
if (
media.length > 0 ||
documents.length > 0 ||
audio.length > 0 ||
links.length > 0 ||
haveOldestDocument ||
documents.length > 0 ||
haveOldestMedia ||
haveOldestLink
haveOldestAudio ||
haveOldestLink ||
haveOldestDocument
) {
return;
}
@@ -204,13 +222,15 @@ export function MediaGallery({
loadingRef.current = true;
}, [
conversationId,
haveOldestDocument,
haveOldestMedia,
haveOldestDocument,
haveOldestAudio,
haveOldestLink,
initialLoad,
media,
documents,
links,
media.length,
audio.length,
links.length,
documents.length,
]);
const previousLoading = usePrevious(loading, loading);
@@ -242,17 +262,23 @@ export function MediaGallery({
loadMore(conversationId, 'media');
loadingRef.current = true;
}
} else if (tabViewRef.current === TabViews.Audio) {
if (!haveOldestMedia) {
loadMore(conversationId, 'audio');
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
} else if (tabViewRef.current === TabViews.Links) {
if (!haveOldestLink) {
loadMore(conversationId, 'links');
loadingRef.current = true;
}
} else {
throw missingCaseError(tabViewRef.current);
}
}
}
@@ -281,6 +307,10 @@ export function MediaGallery({
id: TabViews.Media,
label: i18n('icu:media'),
},
{
id: TabViews.Audio,
label: i18n('icu:MediaGallery__tab__audio'),
},
{
id: TabViews.Links,
label: i18n('icu:MediaGallery__tab__links'),
@@ -297,6 +327,9 @@ export function MediaGallery({
if (selectedTab === TabViews.Media) {
tabViewRef.current = TabViews.Media;
mediaItems = media;
} else if (selectedTab === TabViews.Audio) {
tabViewRef.current = TabViews.Audio;
mediaItems = audio;
} else if (selectedTab === TabViews.Documents) {
tabViewRef.current = TabViews.Documents;
mediaItems = documents;
@@ -308,19 +341,23 @@ export function MediaGallery({
}
return (
<div className="module-media-gallery__content">
<MediaSection
i18n={i18n}
loading={loading}
tab={tabViewRef.current}
mediaItems={mediaItems}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
renderLinkPreviewItem={renderLinkPreviewItem}
/>
</div>
<>
{renderMiniPlayer()}
<div className="module-media-gallery__content">
<MediaSection
i18n={i18n}
loading={loading}
tab={tabViewRef.current}
mediaItems={mediaItems}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
playAudio={playAudio}
renderMediaItem={renderMediaItem}
/>
</div>
</>
);
}}
</Tabs>

View File

@@ -4,7 +4,7 @@
import type { GenericMediaItemType } from '../../../../types/MediaItem.std.js';
import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js';
export type ItemClickEvent = {
export type ItemClickEvent = Readonly<{
state: AttachmentStatusType['state'];
mediaItem: GenericMediaItemType;
};
}>;

View File

@@ -3,6 +3,7 @@
export enum TabViews {
Media = 'Media',
Audio = 'Audio',
Documents = 'Documents',
Links = 'Links',
}

View File

@@ -36,21 +36,33 @@ function createRandomAttachment(fileExtension: string): AttachmentForUIType {
const isDownloaded = Math.random() > 0.4;
const isPending = !isDownloaded && Math.random() > 0.5;
let file: string;
if (fileExtension === 'mp3') {
file = '/fixtures/incompetech-com-Agnus-Dei-X.mp3';
} else if (fileExtension === 'mp4') {
file = '/fixtures/cat-gif.mp4';
} else {
file = '/fixtures/cat-screenshot-3x4.png';
}
let flags = 0;
if (fileExtension === 'mp4' && Math.random() > 0.5) {
flags = SignalService.AttachmentPointer.Flags.GIF;
}
return {
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
url: isDownloaded ? file : undefined,
path: isDownloaded ? 'abc' : undefined,
pending: isPending,
screenshot:
fileExtension === 'mp4'
? {
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
url: isDownloaded ? file : undefined,
contentType: IMAGE_JPEG,
}
: undefined,
flags:
fileExtension === 'mp4' && Math.random() > 0.5
? SignalService.AttachmentPointer.Flags.GIF
: 0,
flags,
width: 400,
height: 300,
fileName,
@@ -80,7 +92,7 @@ function createRandomMessage(
}
function createRandomFile(
type: 'media' | 'document',
type: 'media' | 'document' | 'audio',
startTime: number,
timeWindow: number,
fileExtension: string
@@ -111,7 +123,7 @@ function createRandomLink(
}
function createRandomFiles(
type: 'media' | 'document',
type: 'media' | 'document' | 'audio',
startTime: number,
timeWindow: number,
fileExtensions: Array<string>
@@ -144,6 +156,12 @@ export function createRandomLinks(
createRandomLink(startTime, timeWindow)
);
}
export function createRandomAudio(
startTime: number,
timeWindow: number
): Array<MediaItemType> {
return createRandomFiles('audio', startTime, timeWindow, ['mp3']);
}
export function createRandomMedia(
startTime: number,

View File

@@ -0,0 +1,62 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import type { PropsType } from '../../../../state/smart/MediaItem.dom.js';
import { getSafeDomain } from '../../../../types/LinkPreview.std.js';
import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js';
import { missingCaseError } from '../../../../util/missingCaseError.std.js';
import { LinkPreviewItem } from '../LinkPreviewItem.dom.js';
import { MediaGridItem } from '../MediaGridItem.dom.js';
import { DocumentListItem } from '../DocumentListItem.dom.js';
import { AudioListItem } from '../AudioListItem.dom.js';
const { i18n } = window.SignalContext;
export function MediaItem({ mediaItem, onItemClick }: PropsType): JSX.Element {
const onClick = useCallback(
(state: AttachmentStatusType['state']) => {
onItemClick({ mediaItem, state });
},
[mediaItem, onItemClick]
);
switch (mediaItem.type) {
case 'audio':
return (
<AudioListItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
/>
);
case 'media':
return (
<MediaGridItem mediaItem={mediaItem} onClick={onClick} i18n={i18n} />
);
case 'document':
return (
<DocumentListItem i18n={i18n} mediaItem={mediaItem} onClick={onClick} />
);
case 'link': {
const hydratedMediaItem = {
...mediaItem,
preview: {
...mediaItem.preview,
domain: getSafeDomain(mediaItem.preview.url),
},
};
return (
<LinkPreviewItem
i18n={i18n}
authorTitle="Alice"
mediaItem={hydratedMediaItem}
onClick={onClick}
/>
);
}
default:
throw missingCaseError(mediaItem);
}
}

View File

@@ -28,11 +28,20 @@ export type AttachmentStatusType = Readonly<
export function useAttachmentStatus(
attachment: AttachmentForUIType
): AttachmentStatusType {
const isAttachmentNotAvailable =
attachment.isPermanentlyUndownloadable && !attachment.wasTooBig;
): AttachmentStatusType;
const url = getUrl(attachment);
export function useAttachmentStatus(
attachment: AttachmentForUIType | undefined
): AttachmentStatusType | undefined;
export function useAttachmentStatus(
attachment: AttachmentForUIType | undefined
): AttachmentStatusType | undefined {
const isAttachmentNotAvailable =
attachment == null ||
(attachment.isPermanentlyUndownloadable && !attachment.wasTooBig);
const url = attachment == null ? undefined : getUrl(attachment);
let nextState: InternalState = 'ReadyToShow';
if (attachment && isAttachmentNotAvailable) {
@@ -45,6 +54,10 @@ export function useAttachmentStatus(
const state = useDelayedValue(nextState, TRANSITION_DELAY);
if (attachment == null) {
return undefined;
}
// Idle
if (state === 'NeedsDownload' && nextState === state) {
return { state: 'NeedsDownload' };

View File

@@ -591,7 +591,7 @@ export type GetOlderMediaOptionsType = Readonly<{
messageId?: string;
receivedAt?: number;
sentAt?: number;
type: 'media' | 'documents';
type: 'media' | 'audio' | 'documents';
}>;
export type GetOlderLinkPreviewsOptionsType = Readonly<{

View File

@@ -79,6 +79,7 @@ import {
ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
} from './hydration.std.js';
import { SignalService } from '../protobuf/index.std.js';
import { SeenStatus } from '../MessageSeenStatus.std.js';
import {
attachmentBackupJobSchema,
@@ -5212,8 +5213,7 @@ function hasMedia(db: ReadableDB, conversationId: string): boolean {
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/%'
contentType IS NOT 'text/x-signal-plain'
);
`;
hasAttachments =
@@ -5240,6 +5240,8 @@ function hasMedia(db: ReadableDB, conversationId: string): boolean {
})();
}
const { VOICE_MESSAGE } = SignalService.AttachmentPointer.Flags;
function getOlderMedia(
db: ReadableDB,
{
@@ -5262,14 +5264,24 @@ function getOlderMedia(
let contentFilter: QueryFragment;
if (type === 'media') {
// see 'isVisualMedia' in ts/types/Attachment.ts
// see 'isVisualMedia' in ts/util/Attachment.std.ts
contentFilter = sqlFragment`
message_attachments.contentType LIKE 'image/%' OR
message_attachments.contentType LIKE 'video/%'
message_attachments.flags IS NOT ${VOICE_MESSAGE} AND
(
message_attachments.contentType LIKE 'image/%' OR
message_attachments.contentType LIKE 'video/%'
)
`;
} else if (type === 'audio') {
// see 'isVoiceMessage'/'isAudio' in ts/util/Attachment.std.ts
contentFilter = sqlFragment`
message_attachments.flags IS ${VOICE_MESSAGE} OR
message_attachments.contentType LIKE 'audio/%'
`;
} else if (type === 'documents') {
// see 'isFile' in ts/types/Attachment.ts
// see 'isFile' in ts/util/Attachment.std.ts
contentFilter = sqlFragment`
message_attachments.flags IS NOT ${VOICE_MESSAGE} AND
message_attachments.contentType IS NOT NULL AND
message_attachments.contentType IS NOT '' AND
message_attachments.contentType IS NOT 'text/x-signal-plain' AND

View File

@@ -31,7 +31,12 @@ import type {
MediaItemType,
LinkPreviewMediaItemType,
} from '../../types/MediaItem.std.js';
import { isFile, isVisualMedia } from '../../util/Attachment.std.js';
import {
isFile,
isVisualMedia,
isVoiceMessage,
isAudio,
} 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';
@@ -44,9 +49,11 @@ export type MediaGalleryStateType = ReadonlyDeep<{
conversationId: string | undefined;
haveOldestDocument: boolean;
haveOldestMedia: boolean;
haveOldestAudio: boolean;
haveOldestLink: boolean;
loading: boolean;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
}>;
@@ -63,6 +70,7 @@ type InitialLoadActionType = ReadonlyDeep<{
conversationId: string;
documents: ReadonlyArray<MediaItemType>;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
};
}>;
@@ -71,6 +79,7 @@ type LoadMoreActionType = ReadonlyDeep<{
payload: {
conversationId: string;
media: ReadonlyArray<MediaItemType>;
audio: ReadonlyArray<MediaItemType>;
documents: ReadonlyArray<MediaItemType>;
links: ReadonlyArray<LinkPreviewMediaItemType>;
};
@@ -103,7 +112,7 @@ function _sortItems<
}
function _cleanAttachments(
type: 'media' | 'document',
type: 'media' | 'audio' | 'document',
rawMedia: ReadonlyArray<MediaItemDBType>
): ReadonlyArray<MediaItemType> {
return rawMedia.map(({ message, index, attachment }) => {
@@ -148,24 +157,31 @@ function initialLoad(
payload: { loading: true },
});
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 [rawMedia, rawAudio, rawDocuments, rawLinkPreviews] =
await Promise.all([
DataReader.getOlderMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'media',
}),
DataReader.getOlderMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'audio',
}),
DataReader.getOlderMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
type: 'documents',
}),
DataReader.getOlderLinkPreviews({
conversationId,
limit: FETCH_CHUNK_COUNT,
}),
]);
const media = _cleanAttachments('media', rawMedia);
const audio = _cleanAttachments('audio', rawAudio);
const documents = _cleanAttachments('document', rawDocuments);
const links = _cleanLinkPreviews(rawLinkPreviews);
@@ -175,6 +191,7 @@ function initialLoad(
conversationId,
documents,
media,
audio,
links,
},
});
@@ -183,7 +200,7 @@ function initialLoad(
function loadMore(
conversationId: string,
type: 'media' | 'documents' | 'links'
type: 'media' | 'audio' | 'documents' | 'links'
): ThunkAction<
void,
RootStateType,
@@ -203,6 +220,8 @@ function loadMore(
let previousItems: ReadonlyArray<MediaItemType | LinkPreviewMediaItemType>;
if (type === 'media') {
previousItems = mediaGallery.media;
} else if (type === 'audio') {
previousItems = mediaGallery.audio;
} else if (type === 'documents') {
previousItems = mediaGallery.documents;
} else if (type === 'links') {
@@ -234,6 +253,7 @@ function loadMore(
};
let media: ReadonlyArray<MediaItemType> = [];
let audio: ReadonlyArray<MediaItemType> = [];
let documents: ReadonlyArray<MediaItemType> = [];
let links: ReadonlyArray<LinkPreviewMediaItemType> = [];
if (type === 'media') {
@@ -243,6 +263,13 @@ function loadMore(
});
media = _cleanAttachments('media', rawMedia);
} else if (type === 'audio') {
const rawAudio = await DataReader.getOlderMedia({
...sharedOptions,
type: 'audio',
});
audio = _cleanAttachments('audio', rawAudio);
} else if (type === 'documents') {
const rawDocuments = await DataReader.getOlderMedia({
...sharedOptions,
@@ -261,6 +288,7 @@ function loadMore(
payload: {
conversationId,
media,
audio,
documents,
links,
},
@@ -282,9 +310,11 @@ export function getEmptyState(): MediaGalleryStateType {
conversationId: undefined,
haveOldestDocument: false,
haveOldestMedia: false,
haveOldestAudio: false,
haveOldestLink: false,
loading: true,
media: [],
audio: [],
documents: [],
links: [],
};
@@ -311,16 +341,18 @@ export function reducer(
loading: false,
conversationId: payload.conversationId,
haveOldestMedia: payload.media.length === 0,
haveOldestDocument: payload.documents.length === 0,
haveOldestAudio: payload.audio.length === 0,
haveOldestLink: payload.links.length === 0,
haveOldestDocument: payload.documents.length === 0,
media: _sortItems(payload.media),
documents: _sortItems(payload.documents),
audio: _sortItems(payload.audio),
links: _sortItems(payload.links),
documents: _sortItems(payload.documents),
};
}
if (action.type === LOAD_MORE) {
const { conversationId, media, documents, links } = action.payload;
const { conversationId, media, audio, documents, links } = action.payload;
if (state.conversationId !== conversationId) {
return state;
}
@@ -329,11 +361,13 @@ export function reducer(
...state,
loading: false,
haveOldestMedia: media.length === 0,
haveOldestAudio: audio.length === 0,
haveOldestDocument: documents.length === 0,
haveOldestLink: links.length === 0,
media: _sortItems(media.concat(state.media)),
documents: _sortItems(documents.concat(state.documents)),
audio: _sortItems(audio.concat(state.audio)),
links: _sortItems(links.concat(state.links)),
documents: _sortItems(documents.concat(state.documents)),
};
}
@@ -351,6 +385,9 @@ export function reducer(
const mediaWithout = state.media.filter(
item => item.message.id !== message.id
);
const audioWithout = state.audio.filter(
item => item.message.id !== message.id
);
const documentsWithout = state.documents.filter(
item => item.message.id !== message.id
);
@@ -358,14 +395,21 @@ export function reducer(
item => item.message.id !== message.id
);
const mediaDifference = state.media.length - mediaWithout.length;
const audioDifference = state.audio.length - audioWithout.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 || linkDifference > 0) {
if (
mediaDifference > 0 ||
audioDifference > 0 ||
documentDifference > 0 ||
linkDifference > 0
) {
return {
...state,
media: mediaWithout,
audio: audioWithout,
documents: documentsWithout,
links: linksWithout,
};
@@ -374,6 +418,7 @@ export function reducer(
}
const oldestLoadedMedia = state.media[0];
const oldestLoadedAudio = state.audio[0];
const oldestLoadedDocument = state.documents[0];
const oldestLoadedLink = state.links[0];
@@ -400,6 +445,12 @@ export function reducer(
'media',
messageMediaItems.filter(({ attachment }) => isVisualMedia(attachment))
);
const newAudio = _cleanAttachments(
'audio',
messageMediaItems.filter(
({ attachment }) => isVoiceMessage(attachment) || isAudio([attachment])
)
);
const newDocuments = _cleanAttachments(
'document',
messageMediaItems.filter(({ attachment }) => isFile(attachment))
@@ -425,12 +476,14 @@ export function reducer(
);
let {
documents,
haveOldestDocument,
haveOldestMedia,
media,
haveOldestLink,
audio,
links,
documents,
haveOldestMedia,
haveOldestAudio,
haveOldestLink,
haveOldestDocument,
} = state;
const inMediaTimeRange =
@@ -443,6 +496,16 @@ export function reducer(
haveOldestMedia = false;
}
const inAudioTimeRange =
!oldestLoadedAudio ||
(message.received_at >= oldestLoadedAudio.message.receivedAt &&
message.sent_at >= oldestLoadedAudio.message.sentAt);
if ((audioDifference > 0 || newAudio.length > 0) && inAudioTimeRange) {
audio = _sortItems(audioWithout.concat(newAudio));
} else if (!inAudioTimeRange) {
haveOldestAudio = false;
}
const inDocumentTimeRange =
!oldestLoadedDocument ||
(message.received_at >= oldestLoadedDocument.message.receivedAt &&
@@ -467,21 +530,25 @@ export function reducer(
}
if (
state.haveOldestDocument !== haveOldestDocument ||
state.haveOldestMedia !== haveOldestMedia ||
state.haveOldestAudio !== haveOldestAudio ||
state.haveOldestLink !== haveOldestLink ||
state.documents !== documents ||
state.haveOldestDocument !== haveOldestDocument ||
state.media !== media ||
state.links !== links
state.audio !== audio ||
state.links !== links ||
state.documents !== documents
) {
return {
...state,
documents,
haveOldestDocument,
haveOldestMedia,
haveOldestAudio,
haveOldestLink,
haveOldestDocument,
media,
audio,
links,
documents,
};
}
@@ -492,6 +559,7 @@ export function reducer(
return {
...state,
media: state.media.filter(item => item.message.id !== action.payload.id),
audio: state.audio.filter(item => item.message.id !== action.payload.id),
links: state.links.filter(item => item.message.id !== action.payload.id),
documents: state.documents.filter(
item => item.message.id !== action.payload.id

View File

@@ -1,24 +1,36 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { MediaGallery } from '../../components/conversation/media-gallery/MediaGallery.dom.js';
import { createLogger } from '../../logging/log.std.js';
import type { MediaItemType } from '../../types/MediaItem.std.js';
import { getMessageById } from '../../messages/getMessageById.preload.js';
import { getMediaGalleryState } from '../selectors/mediaGallery.std.js';
import { getIntl, getTheme } from '../selectors/user.std.js';
import { extractVoiceNoteForPlayback } from '../selectors/audioPlayer.preload.js';
import { getIntl, getUserConversationId } 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 { useAudioPlayerActions } from '../ducks/audioPlayer.preload.js';
import {
SmartLinkPreviewItem,
type PropsType as LinkPreviewItemPropsType,
} from './LinkPreviewItem.dom.js';
MediaItem,
type PropsType as MediaItemPropsType,
} from './MediaItem.dom.js';
import { SmartMiniPlayer } from './MiniPlayer.preload.js';
const log = createLogger('AllMedia');
export type PropsType = {
conversationId: string;
};
function renderLinkPreviewItem(props: LinkPreviewItemPropsType): JSX.Element {
return <SmartLinkPreviewItem {...props} />;
function renderMiniPlayer(): JSX.Element {
return <SmartMiniPlayer shouldFlow />;
}
function renderMediaItem(props: MediaItemPropsType): JSX.Element {
return <MediaItem {...props} />;
}
export const SmartAllMedia = memo(function SmartAllMedia({
@@ -26,11 +38,13 @@ export const SmartAllMedia = memo(function SmartAllMedia({
}: PropsType) {
const {
media,
documents,
audio,
links,
haveOldestDocument,
documents,
haveOldestMedia,
haveOldestAudio,
haveOldestLink,
haveOldestDocument,
loading,
} = useSelector(getMediaGalleryState);
const { initialLoad, loadMore } = useMediaGalleryActions();
@@ -40,28 +54,86 @@ export const SmartAllMedia = memo(function SmartAllMedia({
cancelAttachmentDownload,
} = useConversationsActions();
const { showLightbox } = useLightboxActions();
const { loadVoiceNoteAudio } = useAudioPlayerActions();
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
const ourConversationId = useSelector(getUserConversationId);
const playAudio = useCallback(
async (mediaItem: MediaItemType) => {
const fullMessage = await getMessageById(mediaItem.message.id);
if (fullMessage == null) {
log.warn('message not found', {
message: mediaItem.message.id,
});
return;
}
const voiceNote = extractVoiceNoteForPlayback(
fullMessage.attributes,
ourConversationId
);
if (!voiceNote) {
log.warn('voice note not found', {
message: mediaItem.message.id,
});
return;
}
if (!ourConversationId) {
log.warn('no ourConversationId');
return;
}
const index = audio.indexOf(mediaItem);
if (index === -1) {
log.warn('audio no longer loaded');
return;
}
const prev = index === 0 ? undefined : audio.at(index - 1);
const next = audio.at(index);
loadVoiceNoteAudio({
voiceNoteData: {
voiceNote,
conversationId: mediaItem.message.conversationId,
previousMessageId: prev?.message.id,
playbackRate: 1,
consecutiveVoiceNotes: [],
nextMessageTimestamp: next?.message.sentAt,
},
position: 0,
context: 'AllMedia',
ourConversationId,
playbackRate: 1,
});
},
[audio, loadVoiceNoteAudio, ourConversationId]
);
return (
<MediaGallery
conversationId={conversationId}
haveOldestDocument={haveOldestDocument}
haveOldestMedia={haveOldestMedia}
haveOldestAudio={haveOldestAudio}
haveOldestLink={haveOldestLink}
haveOldestDocument={haveOldestDocument}
i18n={i18n}
theme={theme}
initialLoad={initialLoad}
loading={loading}
loadMore={loadMore}
media={media}
documents={documents}
audio={audio}
links={links}
documents={documents}
showLightbox={showLightbox}
playAudio={playAudio}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
saveAttachment={saveAttachment}
renderLinkPreviewItem={renderLinkPreviewItem}
renderMediaItem={renderMediaItem}
renderMiniPlayer={renderMiniPlayer}
/>
);
});

View File

@@ -1,42 +0,0 @@
// 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

@@ -0,0 +1,89 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { LinkPreviewItem } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js';
import { MediaGridItem } from '../../components/conversation/media-gallery/MediaGridItem.dom.js';
import { DocumentListItem } from '../../components/conversation/media-gallery/DocumentListItem.dom.js';
import { AudioListItem } from '../../components/conversation/media-gallery/AudioListItem.dom.js';
import type { ItemClickEvent } from '../../components/conversation/media-gallery/types/ItemClickEvent.std.js';
import { getSafeDomain } from '../../types/LinkPreview.std.js';
import type { GenericMediaItemType } from '../../types/MediaItem.std.js';
import type { AttachmentStatusType } from '../../hooks/useAttachmentStatus.std.js';
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { getIntl, getTheme } from '../selectors/user.std.js';
import { getConversationSelector } from '../selectors/conversations.dom.js';
export type PropsType = Readonly<{
onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType;
}>;
export const MediaItem = memo(function MediaItem({
mediaItem,
onItemClick,
}: PropsType) {
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
const getConversation = useSelector(getConversationSelector);
const authorTitle =
mediaItem.message.type === 'outgoing'
? i18n('icu:you')
: getConversation(
mediaItem.message.sourceServiceId ?? mediaItem.message.source
).title;
const onClick = useCallback(
(state: AttachmentStatusType['state']) => {
onItemClick({ mediaItem, state });
},
[mediaItem, onItemClick]
);
switch (mediaItem.type) {
case 'audio':
return (
<AudioListItem
i18n={i18n}
authorTitle={authorTitle}
mediaItem={mediaItem}
onClick={onClick}
/>
);
case 'media':
return (
<MediaGridItem
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
theme={theme}
/>
);
case 'document':
return (
<DocumentListItem i18n={i18n} mediaItem={mediaItem} onClick={onClick} />
);
case 'link': {
const hydratedMediaItem = {
...mediaItem,
preview: {
...mediaItem.preview,
domain: getSafeDomain(mediaItem.preview.url),
},
};
return (
<LinkPreviewItem
i18n={i18n}
theme={theme}
authorTitle={authorTitle}
mediaItem={hydratedMediaItem}
onClick={onClick}
/>
);
}
default:
throw missingCaseError(mediaItem);
}
});

View File

@@ -18,7 +18,7 @@ export type MediaItemMessageType = Readonly<{
}>;
export type MediaItemType = {
type: 'media' | 'document';
type: 'media' | 'audio' | 'document';
attachment: AttachmentForUIType;
index: number;
message: MediaItemMessageType;

View File

@@ -12,12 +12,15 @@ import type {
LocalAttachmentV2Type,
} from './Attachment.std.js';
import {
isAudio,
isVoiceMessage,
removeSchemaVersion,
replaceUnicodeOrderOverrides,
replaceUnicodeV2,
shouldGenerateThumbnailForAttachmentType,
} from '../util/Attachment.std.js';
import { captureDimensionsAndScreenshot } from '../util/captureDimensionsAndScreenshot.dom.js';
import { captureAudioDuration } from '../util/captureAudioDuration.dom.js';
import type { MakeVideoScreenshotResultType } from './VisualAttachment.dom.js';
import * as Errors from './errors.std.js';
import * as SchemaVersion from './SchemaVersion.std.js';
@@ -822,22 +825,30 @@ export const processNewAttachment = async (
throw new TypeError('context.logger is required');
}
const finalAttachment = await captureDimensionsAndScreenshot(
attachment,
{
generateThumbnail:
shouldGenerateThumbnailForAttachmentType(attachmentType),
},
{
writeNewAttachmentData,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
let finalAttachment: AttachmentType;
if (isVoiceMessage(attachment) || isAudio([attachment])) {
finalAttachment = await captureAudioDuration(attachment, {
logger,
}
);
});
} else {
finalAttachment = await captureDimensionsAndScreenshot(
attachment,
{
generateThumbnail:
shouldGenerateThumbnailForAttachmentType(attachmentType),
},
{
writeNewAttachmentData,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
}
);
}
return finalAttachment;
};

View File

@@ -0,0 +1,47 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment.std.js';
import type { LoggerType } from '../types/Logging.std.js';
import { getLocalAttachmentUrl } from './getLocalAttachmentUrl.std.js';
import { toLogFormat } from '../types/errors.std.js';
export async function captureAudioDuration(
attachment: AttachmentType,
{
logger,
}: {
logger: LoggerType;
}
): Promise<AttachmentType> {
const audio = new window.Audio();
audio.muted = true;
audio.src = getLocalAttachmentUrl(attachment);
try {
await new Promise<void>((resolve, reject) => {
audio.addEventListener('loadedmetadata', () => {
resolve();
});
audio.addEventListener('error', event => {
const error = new Error(
`Failed to load audio from due to error: ${event.type}`
);
reject(error);
});
});
} catch (error) {
logger.warn(`captureAudioDuration failed ${toLogFormat(error)}`);
return attachment;
}
if (!Number.isNaN(audio.duration)) {
return {
...attachment,
duration: audio.duration,
};
}
return attachment;
}