Media Gallery improvements

This commit is contained in:
Fedor Indutny
2025-11-20 10:52:17 -08:00
committed by GitHub
parent 879d5946fa
commit 60bb04a4fc
26 changed files with 235 additions and 60 deletions

View File

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

View File

@@ -675,6 +675,7 @@ function ReplyOrReactionMessage({
id={reply.id}
interactionMode="mouse"
isSpoilerExpanded={isSpoilerExpanded}
isVoiceMessagePlayed={false}
messageExpanded={messageExpanded}
readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal"

View File

@@ -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<number, boolean>;
isVoiceMessagePlayed: boolean;
canEndPoll?: boolean;
direction: DirectionType;
timestamp: number;
@@ -1145,11 +1145,11 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
id,
isSticker,
isVoiceMessagePlayed,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
pushPanelForConversation,
quote,
readStatus,
renderAudioAttachment,
renderingContext,
retryMessageSend,
@@ -1282,8 +1282,6 @@ export class Message extends React.PureComponent<Props, State> {
}
if (isAttachmentAudio) {
const played = isPlayed(direction, status, readStatus);
return renderAudioAttachment({
i18n,
buttonRef: this.audioButtonRef,
@@ -1299,7 +1297,7 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp,
id,
conversationId,
played,
played: isVoiceMessagePlayed,
pushPanelForConversation,
status,
textPending: textAttachment?.pending,

View File

@@ -36,6 +36,7 @@ const defaultMessage: MessageDataPropsType = {
isSelectMode: false,
isSMS: false,
isSpoilerExpanded: {},
isVoiceMessagePlayed: false,
previews: [],
readStatus: ReadStatus.Read,
status: 'sent',

View File

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

View File

@@ -71,6 +71,7 @@ function mockMessageTimelineItem(
isSelectMode: false,
isSMS: false,
isSpoilerExpanded: {},
isVoiceMessagePlayed: false,
previews: [],
readStatus: ReadStatus.Read,
canRetryDeleteForEveryone: true,

View File

@@ -286,6 +286,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired,
isVoiceMessagePlayed: false,
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),

View File

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

View File

@@ -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({
</div>
);
const dot = (
<motion.div
className={tw('size-1.5 shrink-0 rounded bg-label-secondary')}
initial={false}
animate={{ scale: isPlayed ? 0 : 1 }}
transition={DOT_TRANSITION}
/>
);
return (
<ListItem
i18n={i18n}
mediaItem={mediaItem}
thumbnail={thumbnail}
title={fileName == null ? authorTitle : `${fileName} · ${authorTitle}`}
subtitle={subtitle.join(' · ')}
subtitle={
<div className={tw('flex items-center gap-1')}>
<div className={tw('truncate overflow-hidden')}>
{subtitle.join(' · ')}
</div>
{dot}
</div>
}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
onShowMessage={onShowMessage}

View File

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

View File

@@ -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<string>();
if (fileName) {
title.push(fileName);
}
title.push(authorTitle);
return (
<ListItem
i18n={i18n}
mediaItem={mediaItem}
thumbnail={<FileThumbnail {...attachment} />}
title={fileName}
title={title.join(' · ')}
subtitle={subtitle}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}

View File

@@ -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({
>
<div className={tw('shrink-0')}>{thumbnail}</div>
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate')}>{title}</h3>
<h3 className={tw('truncate')}>
<UserText text={title} />
</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{subtitle}
</div>

View File

@@ -65,6 +65,10 @@ const createMediaItem = (
// Unused for now
source: undefined,
sourceServiceId: undefined,
readStatus: undefined,
isErased: false,
errors: undefined,
sendStateByConversationId: undefined,
},
});

View File

@@ -88,6 +88,10 @@ function createRandomMessage(
// Unused for now
source: undefined,
sourceServiceId: undefined,
isErased: false,
readStatus: undefined,
sendStateByConversationId: undefined,
errors: undefined,
};
}

View File

@@ -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 {
<AudioListItem
i18n={i18n}
authorTitle="Alice"
isPlayed={isVoiceMessagePlayed(mediaItem.message, undefined)}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
@@ -44,6 +46,7 @@ export function MediaItem({ mediaItem, onItemClick }: PropsType): JSX.Element {
return (
<DocumentListItem
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}

View File

@@ -5368,6 +5368,10 @@ function getSortedMedia(
const createQuery = (timeFilter: QueryFragment): QueryFragment => 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],
};

View File

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

View File

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

View File

@@ -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<MediaItemDBType>
@@ -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),
},
]
: []

View File

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

View File

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

View File

@@ -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({
<AudioListItem
i18n={i18n}
authorTitle={authorTitle}
isPlayed={isVoiceMessagePlayed(mediaItem.message, ourConversationId)}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
@@ -75,6 +82,7 @@ export const MediaItem = memo(function MediaItem({
return (
<DocumentListItem
i18n={i18n}
authorTitle={authorTitle}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}

View File

@@ -31,8 +31,14 @@ const toMediaItem = (id: string, date: Date): MediaItemType => {
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',

View File

@@ -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<CustomError> | undefined;
}>;
export type MediaItemType = {

View File

@@ -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<AttachmentType>): 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<AttachmentType>
): boolean {

View File

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