mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +00:00
Audio tab in media gallery
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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: '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
ts/components/conversation/media-gallery/AudioListItem.dom.tsx
Normal file
107
ts/components/conversation/media-gallery/AudioListItem.dom.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
</>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
133
ts/components/conversation/media-gallery/ListItem.dom.tsx
Normal file
133
ts/components/conversation/media-gallery/ListItem.dom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
export enum TabViews {
|
||||
Media = 'Media',
|
||||
Audio = 'Audio',
|
||||
Documents = 'Documents',
|
||||
Links = 'Links',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
89
ts/state/smart/MediaItem.dom.tsx
Normal file
89
ts/state/smart/MediaItem.dom.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export type MediaItemMessageType = Readonly<{
|
||||
}>;
|
||||
|
||||
export type MediaItemType = {
|
||||
type: 'media' | 'document';
|
||||
type: 'media' | 'audio' | 'document';
|
||||
attachment: AttachmentForUIType;
|
||||
index: number;
|
||||
message: MediaItemMessageType;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
47
ts/util/captureAudioDuration.dom.ts
Normal file
47
ts/util/captureAudioDuration.dom.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user