From 60bb04a4fcea9864a849e216035ffabe7893431c Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:52:17 -0800 Subject: [PATCH] Media Gallery improvements --- ts/components/Lightbox.dom.stories.tsx | 16 ++++++ ts/components/StoryViewsNRepliesModal.dom.tsx | 1 + ts/components/conversation/Message.dom.tsx | 8 ++- .../MessageDetail.dom.stories.tsx | 1 + .../conversation/Quote.dom.stories.tsx | 1 + .../conversation/Timeline.dom.stories.tsx | 1 + .../TimelineMessage.dom.stories.tsx | 1 + .../AudioListItem.dom.stories.tsx | 1 + .../media-gallery/AudioListItem.dom.tsx | 29 +++++++++- .../DocumentListItem.dom.stories.tsx | 1 + .../media-gallery/DocumentListItem.dom.tsx | 10 +++- .../media-gallery/ListItem.dom.tsx | 7 ++- .../MediaGridItem.dom.stories.tsx | 4 ++ .../media-gallery/utils/mocks.std.ts | 4 ++ .../media-gallery/utils/storybook.dom.tsx | 3 ++ ts/sql/Server.node.ts | 21 ++++++++ ts/state/ducks/audioPlayer.preload.ts | 17 +++--- ts/state/ducks/lightbox.preload.ts | 8 +++ ts/state/ducks/mediaGallery.preload.ts | 42 ++++++++------- ts/state/selectors/audioPlayer.preload.ts | 26 +++++---- ts/state/selectors/message.preload.ts | 2 + ts/state/smart/MediaItem.preload.tsx | 10 +++- .../groupMediaItemsByDate.std.ts | 6 +++ ts/types/MediaItem.std.ts | 8 ++- ts/util/Attachment.std.ts | 13 ----- ts/util/isVoiceMessagePlayed.std.ts | 54 +++++++++++++++++++ 26 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 ts/util/isVoiceMessagePlayed.std.ts diff --git a/ts/components/Lightbox.dom.stories.tsx b/ts/components/Lightbox.dom.stories.tsx index 3ac906f490..7a1c7b5416 100644 --- a/ts/components/Lightbox.dom.stories.tsx +++ b/ts/components/Lightbox.dom.stories.tsx @@ -58,6 +58,10 @@ function createMediaItem( // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, ...overrideProps, }; @@ -110,6 +114,10 @@ export function Multimedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, { @@ -130,6 +138,10 @@ export function Multimedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, createMediaItem({ @@ -170,6 +182,10 @@ export function MissingMedia(): JSX.Element { // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, }, ], diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 84f5f0ab97..4613b5da31 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -675,6 +675,7 @@ function ReplyOrReactionMessage({ id={reply.id} interactionMode="mouse" isSpoilerExpanded={isSpoilerExpanded} + isVoiceMessagePlayed={false} messageExpanded={messageExpanded} readStatus={reply.readStatus} renderingContext="StoryViewsNRepliesModal" diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 73b0c87dc8..494e7db2a1 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -70,7 +70,6 @@ import { isGIF, isImage, isImageAttachment, - isPlayed, isVideo, } from '../../util/Attachment.std.js'; import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact.std.js'; @@ -250,6 +249,7 @@ export type PropsData = { isSelectMode: boolean; isSMS: boolean; isSpoilerExpanded?: Record; + isVoiceMessagePlayed: boolean; canEndPoll?: boolean; direction: DirectionType; timestamp: number; @@ -1145,11 +1145,11 @@ export class Message extends React.PureComponent { i18n, id, isSticker, + isVoiceMessagePlayed, kickOffAttachmentDownload, markAttachmentAsCorrupted, pushPanelForConversation, quote, - readStatus, renderAudioAttachment, renderingContext, retryMessageSend, @@ -1282,8 +1282,6 @@ export class Message extends React.PureComponent { } if (isAttachmentAudio) { - const played = isPlayed(direction, status, readStatus); - return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, @@ -1299,7 +1297,7 @@ export class Message extends React.PureComponent { expirationTimestamp, id, conversationId, - played, + played: isVoiceMessagePlayed, pushPanelForConversation, status, textPending: textAttachment?.pending, diff --git a/ts/components/conversation/MessageDetail.dom.stories.tsx b/ts/components/conversation/MessageDetail.dom.stories.tsx index b710d3207c..651b6f0801 100644 --- a/ts/components/conversation/MessageDetail.dom.stories.tsx +++ b/ts/components/conversation/MessageDetail.dom.stories.tsx @@ -36,6 +36,7 @@ const defaultMessage: MessageDataPropsType = { isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, status: 'sent', diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index d6a89d9d00..120165cc82 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -108,6 +108,7 @@ const defaultMessageProps: TimelineMessagesProps = { isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, toggleSelectMessage: action('toggleSelectMessage'), cancelAttachmentDownload: action('default--cancelAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 3b7e48f3fc..3873fc3977 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -71,6 +71,7 @@ function mockMessageTimelineItem( isSelectMode: false, isSMS: false, isSpoilerExpanded: {}, + isVoiceMessagePlayed: false, previews: [], readStatus: ReadStatus.Read, canRetryDeleteForEveryone: true, diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index b059204bab..7e0ca9babb 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -286,6 +286,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToView: overrideProps.isTapToView, isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, + isVoiceMessagePlayed: false, cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx index 23b1f7b0f6..be42d9a502 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx @@ -27,6 +27,7 @@ export function Multiple(): JSX.Element { i18n={i18n} key={index} mediaItem={mediaItem} + isPlayed={Math.random() > 0.5} authorTitle="Alice" onClick={action('onClick')} onShowMessage={action('onShowMessage')} diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx index 748768c7c7..7b1fb06f09 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx @@ -3,6 +3,8 @@ import React from 'react'; import { noop } from 'lodash'; +import type { Transition } from 'framer-motion'; +import { motion } from 'framer-motion'; import { tw } from '../../../axo/tw.dom.js'; import { formatFileSize } from '../../../util/formatFileSize.std.js'; @@ -17,6 +19,13 @@ const BAR_COUNT = 7; const MAX_PEAK_HEIGHT = 22; const MIN_PEAK_HEIGHT = 2; +const DOT_TRANSITION: Transition = { + type: 'spring', + mass: 0.5, + stiffness: 350, + damping: 20, +}; + export type DataProps = Readonly<{ mediaItem: MediaItemType; onClick: (status: AttachmentStatusType['state']) => void; @@ -29,12 +38,14 @@ export type Props = DataProps & i18n: LocalizerType; theme?: ThemeType; authorTitle: string; + isPlayed: boolean; }>; export function AudioListItem({ i18n, mediaItem, authorTitle, + isPlayed, onClick, onShowMessage, }: Props): JSX.Element { @@ -95,13 +106,29 @@ export function AudioListItem({ ); + const dot = ( + + ); + return ( +
+ {subtitle.join(' · ')} +
+ {dot} + + } readyLabel={i18n('icu:startDownload')} onClick={onClick} onShowMessage={onShowMessage} diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx index 71aa71d903..be577b4a32 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx @@ -27,6 +27,7 @@ export function Multiple(): JSX.Element { i18n={i18n} key={mediaItem.attachment.fileName} mediaItem={mediaItem} + authorTitle="Alice" onClick={action('onClick')} onShowMessage={action('onShowMessage')} /> diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx index 6d51d110a7..5890b9c31d 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx @@ -17,6 +17,7 @@ import { ListItem } from './ListItem.dom.js'; export type Props = { i18n: LocalizerType; mediaItem: MediaItemType; + authorTitle: string; onClick: (status: AttachmentStatusType['state']) => void; onShowMessage: () => void; }; @@ -24,6 +25,7 @@ export type Props = { export function DocumentListItem({ i18n, mediaItem, + authorTitle, onClick, onShowMessage, }: Props): JSX.Element { @@ -50,12 +52,18 @@ export function DocumentListItem({ ); + const title = new Array(); + if (fileName) { + title.push(fileName); + } + title.push(authorTitle); + return ( } - title={fileName} + title={title.join(' · ')} subtitle={subtitle} readyLabel={i18n('icu:startDownload')} onClick={onClick} diff --git a/ts/components/conversation/media-gallery/ListItem.dom.tsx b/ts/components/conversation/media-gallery/ListItem.dom.tsx index 9826de28f9..eff53cb245 100644 --- a/ts/components/conversation/media-gallery/ListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/ListItem.dom.tsx @@ -12,6 +12,7 @@ import { SpinnerV2 } from '../../SpinnerV2.dom.js'; import { tw } from '../../../axo/tw.dom.js'; import { AriaClickable } from '../../../axo/AriaClickable.dom.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; +import { UserText } from '../../UserText.dom.js'; import { useAttachmentStatus, type AttachmentStatusType, @@ -21,7 +22,7 @@ export type Props = { i18n: LocalizerType; mediaItem: GenericMediaItemType; thumbnail: React.ReactNode; - title: React.ReactNode; + title: string; subtitle: React.ReactNode; readyLabel: string; onClick: (status: AttachmentStatusType['state']) => void; @@ -129,7 +130,9 @@ export function ListItem({ >
{thumbnail}
-

{title}

+

+ +

{subtitle}
diff --git a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx index aee61b9fe9..0286b45ae8 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.dom.stories.tsx @@ -65,6 +65,10 @@ const createMediaItem = ( // Unused for now source: undefined, sourceServiceId: undefined, + readStatus: undefined, + isErased: false, + errors: undefined, + sendStateByConversationId: undefined, }, }); diff --git a/ts/components/conversation/media-gallery/utils/mocks.std.ts b/ts/components/conversation/media-gallery/utils/mocks.std.ts index 7e48f27648..3baeb688b6 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.std.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.std.ts @@ -88,6 +88,10 @@ function createRandomMessage( // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }; } diff --git a/ts/components/conversation/media-gallery/utils/storybook.dom.tsx b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx index 841958c222..24ba269e86 100644 --- a/ts/components/conversation/media-gallery/utils/storybook.dom.tsx +++ b/ts/components/conversation/media-gallery/utils/storybook.dom.tsx @@ -9,6 +9,7 @@ import type { PropsType } from '../../../../state/smart/MediaItem.preload.js'; import { getSafeDomain } from '../../../../types/LinkPreview.std.js'; import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.std.js'; import { missingCaseError } from '../../../../util/missingCaseError.std.js'; +import { isVoiceMessagePlayed } from '../../../../util/isVoiceMessagePlayed.std.js'; import { LinkPreviewItem } from '../LinkPreviewItem.dom.js'; import { MediaGridItem } from '../MediaGridItem.dom.js'; import { DocumentListItem } from '../DocumentListItem.dom.js'; @@ -31,6 +32,7 @@ export function MediaItem({ mediaItem, onItemClick }: PropsType): JSX.Element { sqlFragment` SELECT message_attachments.*, + messages.json -> '$.sendStateByConversationId' AS messageSendState, + messages.json -> '$.errors' AS messageErrors, + messages.isErased AS messageIsErased, + messages.readStatus AS messageReadStatus, messages.source AS messageSource, messages.sourceServiceId AS messageSourceServiceId FROM message_attachments @@ -5399,6 +5403,10 @@ function getSortedMedia( const results: Array< MessageAttachmentDBType & { + messageSendState: string | null; + messageErrors: string | null; + messageIsErased: number | null; + messageReadStatus: ReadStatus | null; messageSource: string | null; messageSourceServiceId: ServiceIdString | null; } @@ -5410,6 +5418,10 @@ function getSortedMedia( messageType, messageSource, messageSourceServiceId, + messageSendState, + messageErrors, + messageIsErased, + messageReadStatus, sentAt, receivedAt, receivedAtMs, @@ -5425,6 +5437,11 @@ function getSortedMedia( receivedAt, receivedAtMs: receivedAtMs ?? undefined, sentAt, + sendStateByConversationId: + messageSendState == null ? undefined : JSON.parse(messageSendState), + errors: messageErrors == null ? undefined : JSON.parse(messageErrors), + isErased: messageIsErased === 1, + readStatus: messageReadStatus ?? undefined, }, index: orderInMessage, attachment: convertAttachmentDBFieldsToAttachmentType(attachment), @@ -5487,6 +5504,10 @@ function getOlderLinkPreviews( receivedAt: message.received_at, receivedAtMs: message.received_at_ms ?? undefined, sentAt: message.sent_at, + errors: message.errors, + sendStateByConversationId: message.sendStateByConversationId, + readStatus: message.readStatus, + isErased: !!message.isErased, }, preview: message.preview[0], }; diff --git a/ts/state/ducks/audioPlayer.preload.ts b/ts/state/ducks/audioPlayer.preload.ts index 1bb4723a82..324bbc7301 100644 --- a/ts/state/ducks/audioPlayer.preload.ts +++ b/ts/state/ducks/audioPlayer.preload.ts @@ -28,7 +28,6 @@ import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl.std.js'; import { assertDev } from '../../util/assert.std.js'; import { drop } from '../../util/drop.std.js'; import { Sound, SoundType } from '../../util/Sound.std.js'; -import { getMessageById } from '../../messages/getMessageById.preload.js'; import { DataReader } from '../../sql/Client.preload.js'; const stateChangeConfirmUpSound = new Sound({ @@ -110,12 +109,16 @@ async function getNextVoiceNote({ return undefined; } - const next = await getMessageById(results[0].message.id); - if (next == null) { - return undefined; - } - - return extractVoiceNoteForPlayback(next.attributes, ourConversationId); + const { message, attachment } = results[0]; + return extractVoiceNoteForPlayback( + { + ...message, + attachments: [attachment], + sent_at: message.sentAt, + received_at: message.receivedAt, + }, + ourConversationId + ); } // Actions diff --git a/ts/state/ducks/lightbox.preload.ts b/ts/state/ducks/lightbox.preload.ts index cb271f9aef..89a7d1e5de 100644 --- a/ts/state/ducks/lightbox.preload.ts +++ b/ts/state/ducks/lightbox.preload.ts @@ -233,6 +233,10 @@ function showLightboxForViewOnceMedia( sentAt: message.get('sent_at'), source: message.get('source'), sourceServiceId: message.get('sourceServiceId'), + isErased: !!message.get('isErased'), + readStatus: message.get('readStatus'), + sendStateByConversationId: message.get('sendStateByConversationId'), + errors: message.get('errors'), }, }, ]; @@ -338,6 +342,10 @@ function showLightbox(opts: { source: message.get('source'), sourceServiceId: message.get('sourceServiceId'), sentAt, + isErased: !!message.get('isErased'), + errors: message.get('errors'), + readStatus: message.get('readStatus'), + sendStateByConversationId: message.get('sendStateByConversationId'), }, type: 'media' as const, attachment: getPropsForAttachment( diff --git a/ts/state/ducks/mediaGallery.preload.ts b/ts/state/ducks/mediaGallery.preload.ts index b51ed5282e..a100275a42 100644 --- a/ts/state/ducks/mediaGallery.preload.ts +++ b/ts/state/ducks/mediaGallery.preload.ts @@ -5,6 +5,7 @@ import lodash from 'lodash'; import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; +import type { ReadonlyMessageAttributesType } from '../../model-types.d.ts'; import { createLogger } from '../../logging/log.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import type { @@ -111,6 +112,25 @@ function _sortItems< ]); } +function _cleanMessage( + message: ReadonlyMessageAttributesType +): MediaItemMessageType { + return { + id: message.id, + type: message.type, + source: message.source, + sourceServiceId: message.sourceServiceId, + conversationId: message.conversationId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms, + sentAt: message.sent_at, + isErased: !!message.isErased, + errors: message.errors, + readStatus: message.readStatus, + sendStateByConversationId: message.sendStateByConversationId, + }; +} + function _cleanAttachments( type: 'media' | 'audio' | 'documents', rawMedia: ReadonlyArray @@ -428,16 +448,7 @@ export function reducer( return { index, attachment, - message: { - id: message.id, - type: message.type, - source: message.source, - sourceServiceId: message.sourceServiceId, - conversationId: message.conversationId, - receivedAt: message.received_at, - receivedAtMs: message.received_at_ms, - sentAt: message.sent_at, - }, + message: _cleanMessage(message), }; }); @@ -460,16 +471,7 @@ export function reducer( ? [ { preview: message.preview[0], - message: { - id: message.id, - type: message.type, - source: message.source, - sourceServiceId: message.sourceServiceId, - conversationId: message.conversationId, - receivedAt: message.received_at, - receivedAtMs: message.received_at_ms, - sentAt: message.sent_at, - }, + message: _cleanMessage(message), }, ] : [] diff --git a/ts/state/selectors/audioPlayer.preload.ts b/ts/state/selectors/audioPlayer.preload.ts index 2e3b11c062..6993054644 100644 --- a/ts/state/selectors/audioPlayer.preload.ts +++ b/ts/state/selectors/audioPlayer.preload.ts @@ -8,11 +8,7 @@ import { getUserConversationId, getUserNumber, } from './user.std.js'; -import { - getMessagePropStatus, - getSource, - getSourceServiceId, -} from './message.preload.js'; +import { getSource, getSourceServiceId } from './message.preload.js'; import { getConversationByIdSelector, getConversations, @@ -26,7 +22,7 @@ import type { ReadonlyMessageAttributesType } from '../../model-types.d.ts'; import { getMessageIdForLogging } from '../../util/idForLogging.preload.js'; import * as Attachment from '../../util/Attachment.std.js'; import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer.preload.js'; -import { isPlayed } from '../../util/Attachment.std.js'; +import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; import type { ServiceIdString } from '../../types/ServiceId.std.js'; const log = createLogger('audioPlayer'); @@ -81,7 +77,20 @@ export const selectVoiceNoteTitle = createSelector( ); export function extractVoiceNoteForPlayback( - message: ReadonlyMessageAttributesType, + message: Pick< + ReadonlyMessageAttributesType, + | 'id' + | 'type' + | 'attachments' + | 'isErased' + | 'errors' + | 'readStatus' + | 'sendStateByConversationId' + | 'sent_at' + | 'received_at' + | 'source' + | 'sourceServiceId' + >, ourConversationId: string | undefined ): VoiceNoteForPlayback | undefined { const { type } = message; @@ -98,13 +107,12 @@ export function extractVoiceNoteForPlayback( const voiceNoteUrl = attachment.path ? getLocalAttachmentUrl(attachment) : undefined; - const status = getMessagePropStatus(message, ourConversationId); return { id: message.id, url: voiceNoteUrl, type, - isPlayed: isPlayed(type, status, message.readStatus), + isPlayed: isVoiceMessagePlayed(message, ourConversationId), messageIdForLogging: getMessageIdForLogging(message), sentAt: message.sent_at, receivedAt: message.received_at, diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 1be04bcbd8..a7464c4802 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -87,6 +87,7 @@ import { getLocalAttachmentUrl, AttachmentDisposition, } from '../../util/getLocalAttachmentUrl.std.js'; +import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager.preload.js'; import { getAccountSelector } from './accounts.std.js'; @@ -977,6 +978,7 @@ export const getPropsForMessage = ( isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, isTapToViewExpired: isMessageTapToView && isIncoming(message) && message.isErased, + isVoiceMessagePlayed: isVoiceMessagePlayed(message, ourConversationId), readStatus: message.readStatus ?? ReadStatus.Read, selectedReaction, status: getMessagePropStatus(message, ourConversationId), diff --git a/ts/state/smart/MediaItem.preload.tsx b/ts/state/smart/MediaItem.preload.tsx index ed13121154..c90b432222 100644 --- a/ts/state/smart/MediaItem.preload.tsx +++ b/ts/state/smart/MediaItem.preload.tsx @@ -11,7 +11,12 @@ 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 { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js'; +import { + getIntl, + getTheme, + getUserConversationId, +} from '../selectors/user.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; @@ -26,6 +31,7 @@ export const MediaItem = memo(function MediaItem({ }: PropsType) { const i18n = useSelector(getIntl); const theme = useSelector(getTheme); + const ourConversationId = useSelector(getUserConversationId); const getConversation = useSelector(getConversationSelector); const { showConversation } = useConversationsActions(); @@ -57,6 +63,7 @@ export const MediaItem = memo(function MediaItem({ { receivedAt: date.getTime(), receivedAtMs: date.getTime(), sentAt: date.getTime(), + + // Unused for now source: undefined, sourceServiceId: undefined, + isErased: false, + readStatus: undefined, + sendStateByConversationId: undefined, + errors: undefined, }, attachment: fakeAttachment({ fileName: 'fileName', diff --git a/ts/types/MediaItem.std.ts b/ts/types/MediaItem.std.ts index 0f94214c79..04242b4276 100644 --- a/ts/types/MediaItem.std.ts +++ b/ts/types/MediaItem.std.ts @@ -1,7 +1,9 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { MessageAttributesType } from '../model-types.d.ts'; +import type { MessageAttributesType, CustomError } from '../model-types.d.ts'; +import type { SendStateByConversationId } from '../messages/MessageSendState.std.js'; +import type { ReadStatus } from '../messages/MessageReadStatus.std.js'; import type { AttachmentForUIType } from './Attachment.std.js'; import type { LinkPreviewForUIType } from './message/LinkPreviews.std.js'; import type { ServiceIdString } from './ServiceId.std.js'; @@ -15,6 +17,10 @@ export type MediaItemMessageType = Readonly<{ sentAt: number; source: string | undefined; sourceServiceId: ServiceIdString | undefined; + isErased: boolean; + sendStateByConversationId: SendStateByConversationId | undefined; + readStatus: ReadStatus | undefined; + errors: ReadonlyArray | undefined; }>; export type MediaItemType = { diff --git a/ts/util/Attachment.std.ts b/ts/util/Attachment.std.ts index 355890a753..43dee16245 100644 --- a/ts/util/Attachment.std.ts +++ b/ts/util/Attachment.std.ts @@ -24,8 +24,6 @@ import { } from './GoogleChrome.std.js'; import type { LocalizerType } from '../types/Util.std.js'; import { ThemeType } from '../types/Util.std.js'; -import { ReadStatus } from '../messages/MessageReadStatus.std.js'; -import type { MessageStatusType } from '../types/message/MessageStatus.std.js'; import { isMoreRecentThan } from './timestamp.std.js'; import { DAY } from './durations/index.std.js'; import { @@ -270,17 +268,6 @@ export function isAudio(attachments?: ReadonlyArray): boolean { ); } -export function isPlayed( - direction: 'outgoing' | 'incoming', - status: MessageStatusType | undefined, - readStatus: ReadStatus | undefined -): boolean { - if (direction === 'outgoing') { - return status === 'viewed'; - } - return readStatus === ReadStatus.Viewed; -} - export function canRenderAudio( attachments?: ReadonlyArray ): boolean { diff --git a/ts/util/isVoiceMessagePlayed.std.ts b/ts/util/isVoiceMessagePlayed.std.ts new file mode 100644 index 0000000000..9332fc62b4 --- /dev/null +++ b/ts/util/isVoiceMessagePlayed.std.ts @@ -0,0 +1,54 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReadonlyMessageAttributesType } from '../model-types.d.ts'; +import { isIncoming, isOutgoing } from '../messages/helpers.std.js'; +import { ReadStatus } from '../messages/MessageReadStatus.std.js'; +import { + isSent, + isViewed, + isMessageJustForMe, + getHighestSuccessfulRecipientStatus, +} from '../messages/MessageSendState.std.js'; + +export function isVoiceMessagePlayed( + message: Pick< + ReadonlyMessageAttributesType, + 'type' | 'isErased' | 'errors' | 'readStatus' | 'sendStateByConversationId' + >, + ourConversationId: string | undefined +): boolean { + if (message.isErased) { + return false; + } + + if (message.errors != null && message.errors.length > 0) { + return false; + } + + if (isIncoming(message)) { + return message.readStatus === ReadStatus.Viewed; + } + + if (isOutgoing(message)) { + const { sendStateByConversationId = {} } = message; + + if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) { + return isSent( + getHighestSuccessfulRecipientStatus( + sendStateByConversationId, + undefined + ) + ); + } + + return isViewed( + getHighestSuccessfulRecipientStatus( + sendStateByConversationId, + ourConversationId + ) + ); + } + + return false; +}