From 5745cc00832301066433ca0606368d4f9bdeec5d Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:40:01 -0800 Subject: [PATCH] Audio tab in media gallery --- _locales/en/messages.json | 20 +++ stylesheets/_modules.scss | 2 - .../VoiceNotesPlaybackContext.dom.tsx | 2 +- .../AttachmentSection.dom.stories.tsx | 28 ++-- .../media-gallery/AttachmentSection.dom.tsx | 75 +++------- .../AudioListItem.dom.stories.tsx | 36 +++++ .../media-gallery/AudioListItem.dom.tsx | 107 ++++++++++++++ .../media-gallery/DocumentListItem.dom.tsx | 105 +++----------- .../media-gallery/EmptyState.dom.tsx | 4 + .../media-gallery/LinkPreviewItem.dom.tsx | 78 ++++------ .../media-gallery/ListItem.dom.tsx | 133 +++++++++++++++++ .../MediaGallery.dom.stories.tsx | 26 ++-- .../media-gallery/MediaGallery.dom.tsx | 119 ++++++++++------ .../media-gallery/types/ItemClickEvent.std.ts | 4 +- .../media-gallery/types/TabViews.std.ts | 1 + .../media-gallery/utils/mocks.std.ts | 34 +++-- .../media-gallery/utils/storybook.dom.tsx | 62 ++++++++ ts/hooks/useAttachmentStatus.std.ts | 21 ++- ts/sql/Interface.std.ts | 2 +- ts/sql/Server.node.ts | 24 +++- ts/state/ducks/mediaGallery.preload.ts | 134 +++++++++++++----- ts/state/smart/AllMedia.preload.tsx | 100 +++++++++++-- ts/state/smart/LinkPreviewItem.dom.tsx | 42 ------ ts/state/smart/MediaItem.dom.tsx | 89 ++++++++++++ ts/types/MediaItem.std.ts | 2 +- ts/types/Message2.preload.ts | 41 ++++-- ts/util/captureAudioDuration.dom.ts | 47 ++++++ 27 files changed, 946 insertions(+), 392 deletions(-) create mode 100644 ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx create mode 100644 ts/components/conversation/media-gallery/AudioListItem.dom.tsx create mode 100644 ts/components/conversation/media-gallery/ListItem.dom.tsx create mode 100644 ts/components/conversation/media-gallery/utils/storybook.dom.tsx delete mode 100644 ts/state/smart/LinkPreviewItem.dom.tsx create mode 100644 ts/state/smart/MediaItem.dom.tsx create mode 100644 ts/util/captureAudioDuration.dom.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d30e3f9fef..53985bbeb2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 6343a61dff..f9da93b4d1 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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: ''; diff --git a/ts/components/VoiceNotesPlaybackContext.dom.tsx b/ts/components/VoiceNotesPlaybackContext.dom.tsx index 7bce424a5c..92f29d8129 100644 --- a/ts/components/VoiceNotesPlaybackContext.dom.tsx +++ b/ts/components/VoiceNotesPlaybackContext.dom.tsx @@ -39,7 +39,7 @@ const computeQueue = new PQueue({ concurrency: MAX_PARALLEL_COMPUTE, }); -async function getAudioDuration(buffer: ArrayBuffer): Promise { +export async function getAudioDuration(buffer: ArrayBuffer): Promise { const blob = new Blob([buffer]); const blobURL = URL.createObjectURL(blob); const audio = new Audio(); diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx index 2dcc3cd237..f1f4729731 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.stories.tsx @@ -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 ( - - ); - }, + renderMediaItem: props => , onItemClick: action('onItemClick'), }, } satisfies Meta; @@ -50,3 +40,13 @@ export function Media(args: Props) { const mediaItems = createRandomMedia(Date.now(), days(1)); return ; } + +export function Audio(args: Props) { + const mediaItems = createRandomAudio(Date.now(), days(1)); + return ; +} + +export function Links(args: Props) { + const mediaItems = createRandomLinks(Date.now(), days(1)); + return ; +} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx index bfa82cb8fe..c90971f451 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.dom.tsx @@ -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; - 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; } | { @@ -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({

{header}

{verified.entries.map(mediaItem => { - const onClick = (state: AttachmentStatusType['state']) => { - onItemClick({ mediaItem, state }); - }; - return ( - + + {renderMediaItem({ + mediaItem, + onItemClick, + })} + ); })}
); case 'document': - return ( -
-

{header}

-
- {verified.entries.map(mediaItem => { - const onClick = (state: AttachmentStatusType['state']) => { - onItemClick({ mediaItem, state }); - }; - - return ( - - ); - })} -
-
- ); + case 'audio': case 'link': return ( -
+

{header}

{verified.entries.map(mediaItem => { - const onClick = (state: AttachmentStatusType['state']) => { - onItemClick({ mediaItem, state }); - }; - return ( - {renderLinkPreviewItem({ + {renderMediaItem({ mediaItem, - onClick, + onItemClick, })} ); diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx new file mode 100644 index 0000000000..e824838d75 --- /dev/null +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx @@ -0,0 +1,36 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import type { Props } from './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; + +const { i18n } = window.SignalContext; + +export function Multiple(): JSX.Element { + const items = createPreparedMediaItems(createRandomAudio); + + return ( + <> + {items.map((mediaItem, index) => ( + + ))} + + ); +} diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx new file mode 100644 index 0000000000..5d6b5db62c --- /dev/null +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx @@ -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(); + + 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 = ( +
+ {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 ( +
+ ); + })} +
+ ); + + return ( + + ); +} diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx index 82cb1dcb97..33a1cac769 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx @@ -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 = ( -
- {status.state === 'Downloading' && ( - - )} -
- -
-
- ); } + const subtitle = ( + <> + {glyph} + {typeof fileSize === 'number' ? formatFileSize(fileSize) : ''} + + ); + return ( - + } + title={fileName} + subtitle={subtitle} + readyLabel={i18n('icu:startDownload')} + onClick={onClick} + /> ); } diff --git a/ts/components/conversation/media-gallery/EmptyState.dom.tsx b/ts/components/conversation/media-gallery/EmptyState.dom.tsx index 1d624a12cb..dab170fe6d 100644 --- a/ts/components/conversation/media-gallery/EmptyState.dom.tsx +++ b/ts/components/conversation/media-gallery/EmptyState.dom.tsx @@ -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( diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx index 6159fc811f..dc472ab3b5 100644 --- a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx @@ -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 = ( + <> + + {preview.url} + +
+ {authorTitle} · {preview.domain} + + ); + return ( - + ); } diff --git a/ts/components/conversation/media-gallery/ListItem.dom.tsx b/ts/components/conversation/media-gallery/ListItem.dom.tsx new file mode 100644 index 0000000000..7e62125862 --- /dev/null +++ b/ts/components/conversation/media-gallery/ListItem.dom.tsx @@ -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 = ( +
+ {status.state === 'Downloading' && ( + + )} +
+ +
+
+ ); + } + + return ( + + ); +} diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx index 2e3449c799..76fefdc079 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.stories.tsx @@ -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 => ({ 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 ( - - ); - }, + renderMediaItem: props => , + renderMiniPlayer: () =>
, }); 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 ; } diff --git a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx index f03480d3f1..40598bb3c9 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.dom.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.dom.tsx @@ -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; + audio: ReadonlyArray; documents: ReadonlyArray; links: ReadonlyArray; 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; @@ -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({ ); }); - return
{sections}
; + return ( +
{sections}
+ ); } 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(null); const scrollObserverRef = useRef(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 ( -
- -
+ <> + {renderMiniPlayer()} +
+ +
+ ); }} diff --git a/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts b/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts index e6b91e9e5e..e0e1389449 100644 --- a/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts +++ b/ts/components/conversation/media-gallery/types/ItemClickEvent.std.ts @@ -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; -}; +}>; diff --git a/ts/components/conversation/media-gallery/types/TabViews.std.ts b/ts/components/conversation/media-gallery/types/TabViews.std.ts index c15f1f916d..0597fb1102 100644 --- a/ts/components/conversation/media-gallery/types/TabViews.std.ts +++ b/ts/components/conversation/media-gallery/types/TabViews.std.ts @@ -3,6 +3,7 @@ export enum TabViews { Media = 'Media', + Audio = 'Audio', Documents = 'Documents', Links = 'Links', } diff --git a/ts/components/conversation/media-gallery/utils/mocks.std.ts b/ts/components/conversation/media-gallery/utils/mocks.std.ts index 659645d493..7e48f27648 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.std.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.std.ts @@ -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 @@ -144,6 +156,12 @@ export function createRandomLinks( createRandomLink(startTime, timeWindow) ); } +export function createRandomAudio( + startTime: number, + timeWindow: number +): Array { + return createRandomFiles('audio', startTime, timeWindow, ['mp3']); +} export function createRandomMedia( startTime: number, diff --git a/ts/components/conversation/media-gallery/utils/storybook.dom.tsx b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx new file mode 100644 index 0000000000..28514972ba --- /dev/null +++ b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx @@ -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 ( + + ); + case 'media': + return ( + + ); + case 'document': + return ( + + ); + case 'link': { + const hydratedMediaItem = { + ...mediaItem, + preview: { + ...mediaItem.preview, + domain: getSafeDomain(mediaItem.preview.url), + }, + }; + + return ( + + ); + } + default: + throw missingCaseError(mediaItem); + } +} diff --git a/ts/hooks/useAttachmentStatus.std.ts b/ts/hooks/useAttachmentStatus.std.ts index b18dd8e996..6bc1ba64f6 100644 --- a/ts/hooks/useAttachmentStatus.std.ts +++ b/ts/hooks/useAttachmentStatus.std.ts @@ -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' }; diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index ccd141569f..16c4a5678b 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -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<{ diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index caa6f3ec32..4d8e91b6b1 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -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 diff --git a/ts/state/ducks/mediaGallery.preload.ts b/ts/state/ducks/mediaGallery.preload.ts index 1175f2c0c1..ff54ac8eb0 100644 --- a/ts/state/ducks/mediaGallery.preload.ts +++ b/ts/state/ducks/mediaGallery.preload.ts @@ -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; + audio: ReadonlyArray; documents: ReadonlyArray; links: ReadonlyArray; }>; @@ -63,6 +70,7 @@ type InitialLoadActionType = ReadonlyDeep<{ conversationId: string; documents: ReadonlyArray; media: ReadonlyArray; + audio: ReadonlyArray; links: ReadonlyArray; }; }>; @@ -71,6 +79,7 @@ type LoadMoreActionType = ReadonlyDeep<{ payload: { conversationId: string; media: ReadonlyArray; + audio: ReadonlyArray; documents: ReadonlyArray; links: ReadonlyArray; }; @@ -103,7 +112,7 @@ function _sortItems< } function _cleanAttachments( - type: 'media' | 'document', + type: 'media' | 'audio' | 'document', rawMedia: ReadonlyArray ): ReadonlyArray { 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; 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 = []; + let audio: ReadonlyArray = []; let documents: ReadonlyArray = []; let links: ReadonlyArray = []; 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 diff --git a/ts/state/smart/AllMedia.preload.tsx b/ts/state/smart/AllMedia.preload.tsx index a7696ac2b6..ed67f0dac0 100644 --- a/ts/state/smart/AllMedia.preload.tsx +++ b/ts/state/smart/AllMedia.preload.tsx @@ -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 ; +function renderMiniPlayer(): JSX.Element { + return ; +} + +function renderMediaItem(props: MediaItemPropsType): JSX.Element { + return ; } 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 ( ); }); diff --git a/ts/state/smart/LinkPreviewItem.dom.tsx b/ts/state/smart/LinkPreviewItem.dom.tsx deleted file mode 100644 index 510a5d7a40..0000000000 --- a/ts/state/smart/LinkPreviewItem.dom.tsx +++ /dev/null @@ -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 ( - - ); -}); diff --git a/ts/state/smart/MediaItem.dom.tsx b/ts/state/smart/MediaItem.dom.tsx new file mode 100644 index 0000000000..b9fa89c84d --- /dev/null +++ b/ts/state/smart/MediaItem.dom.tsx @@ -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 ( + + ); + case 'media': + return ( + + ); + case 'document': + return ( + + ); + case 'link': { + const hydratedMediaItem = { + ...mediaItem, + preview: { + ...mediaItem.preview, + domain: getSafeDomain(mediaItem.preview.url), + }, + }; + + return ( + + ); + } + default: + throw missingCaseError(mediaItem); + } +}); diff --git a/ts/types/MediaItem.std.ts b/ts/types/MediaItem.std.ts index e3123f3947..0f94214c79 100644 --- a/ts/types/MediaItem.std.ts +++ b/ts/types/MediaItem.std.ts @@ -18,7 +18,7 @@ export type MediaItemMessageType = Readonly<{ }>; export type MediaItemType = { - type: 'media' | 'document'; + type: 'media' | 'audio' | 'document'; attachment: AttachmentForUIType; index: number; message: MediaItemMessageType; diff --git a/ts/types/Message2.preload.ts b/ts/types/Message2.preload.ts index f77ef33a8a..41180b8622 100644 --- a/ts/types/Message2.preload.ts +++ b/ts/types/Message2.preload.ts @@ -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; }; diff --git a/ts/util/captureAudioDuration.dom.ts b/ts/util/captureAudioDuration.dom.ts new file mode 100644 index 0000000000..0c056ff946 --- /dev/null +++ b/ts/util/captureAudioDuration.dom.ts @@ -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 { + const audio = new window.Audio(); + audio.muted = true; + audio.src = getLocalAttachmentUrl(attachment); + + try { + await new Promise((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; +}