Update styles for MediaGallery

This commit is contained in:
Fedor Indutny 2025-09-10 13:25:21 -07:00 committed by GitHub
commit 53d1650844
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 999 additions and 920 deletions

View file

@ -5918,11 +5918,15 @@
},
"icu:ConversationDetailsMediaList--shared-media": {
"messageformat": "Shared media",
"description": "Title for the media thumbnails in the conversation details screen"
"description": "(Deleted 2025/07/01) Title for the media thumbnails in the conversation details screen"
},
"icu:ConversationDetailsMediaList--show-all": {
"messageformat": "See all",
"description": "This is a button on the conversation details to show all media"
"description": "(Deleted 2025/07/01) This is a button on the conversation details to show all media"
},
"icu:ConversationDetailsMediaList--title": {
"messageformat": "Media, links, and files",
"description": "Title for the show all media button in the conversation details screen"
},
"icu:ConversationDetailsMembershipList--title": {
"messageformat": "{number, plural, one {# member} other {# members}}",

View file

@ -2479,6 +2479,7 @@ button.ConversationDetails__action-button {
display: flex;
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px;
}
@ -2496,161 +2497,13 @@ button.ConversationDetails__action-button {
}
.module-media-gallery__sections {
min-width: 0;
display: flex;
flex-grow: 1;
flex-direction: column;
}
// Module: Attachment Section
.module-attachment-section {
width: 100%;
}
.module-attachment-section__header {
@include mixins.font-body-1-bold;
}
.module-attachment-section__items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
}
// Module: Document List Item
.module-document-list-item {
width: 100%;
height: 72px;
}
.module-document-list-item--with-separator {
@include mixins.light-theme {
border-bottom: 1px solid variables.$color-gray-02;
}
@include mixins.dark-theme {
border-bottom: 1px solid variables.$color-gray-75;
}
}
.module-document-list-item__content {
@include mixins.button-reset;
& {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
@include mixins.keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px variables.$color-ultramarine;
}
}
}
.module-document-list-item__icon {
flex-shrink: 0;
width: 48px;
height: 48px;
@include mixins.color-svg(
'../images/generic-file.svg',
variables.$color-gray-45
);
}
.module-document-list-item__metadata {
display: inline-flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 0;
margin-inline: 8px;
}
.module-document-list-item__file-size {
display: inline-block;
margin-top: 8px;
@include mixins.font-body-2;
}
.module-document-list-item__date {
display: inline-block;
flex-shrink: 0;
}
// Module: Media Grid Item
.module-media-grid-item {
@include mixins.button-reset;
& {
height: 94px;
width: 94px;
background-color: variables.$color-gray-05;
margin-inline-end: 4px;
margin-bottom: 4px;
position: relative;
}
@include mixins.keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px variables.$color-ultramarine;
}
}
}
.module-media-grid-item__image {
height: 94px;
width: 100%;
object-fit: cover;
}
.module-media-grid-item__icon {
position: absolute;
top: 15px;
bottom: 15px;
inset-inline: 15px;
}
.module-media-grid-item__icon-image {
@include mixins.color-svg('../images/image.svg', variables.$color-gray-45);
}
.module-media-grid-item__image-container {
position: relative;
}
.module-media-grid-item__circle-overlay {
@include mixins.position-absolute-center;
width: 42px;
height: 42px;
background-color: variables.$color-white;
border-radius: 21px;
}
.module-media-grid-item__play-overlay {
@include mixins.position-absolute-center;
height: 24px;
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/play/play-fill.svg',
variables.$color-ultramarine
);
}
.module-media-grid-item__icon-video {
@include mixins.color-svg('../images/movie.svg', variables.$color-gray-45);
}
.module-media-grid-item__icon-generic {
@include mixins.color-svg('../images/file.svg', variables.$color-gray-45);
}
/* Module: Empty State*/
.module-empty-state {

View file

@ -131,6 +131,12 @@
}
}
&--media {
&::after {
@include details-icon('../images/icons/v3/album/album-tilt.svg');
}
}
&--mention {
&::after {
@include details-icon('../images/icons/v3/at/at.svg');
@ -282,46 +288,6 @@
}
}
&-media-list {
&__root {
display: flex;
justify-content: center;
padding-block: 0;
padding-inline: 20px;
padding-bottom: 24px;
.module-media-grid-item {
border-radius: 4px;
height: auto;
margin-block: 0;
margin-inline: 4px;
max-height: 94px;
overflow: hidden;
width: calc(100% / 6);
.module-media-grid-item__icon {
&::before {
content: '';
display: block;
padding-top: 100%;
}
}
.module-media-grid-item__image-container,
img {
margin: 0;
}
}
}
&__show-all {
background: none;
border: none;
padding: 0;
color: light-dark(variables.$color-gray-95, variables.$color-gray-05);
}
}
&-panel-row {
$row-root-selector: '#{&}__root';
&__root {

View file

@ -246,7 +246,7 @@ export namespace AxoSymbol {
*/
export type IconProps = Readonly<{
size: 14 | 16 | 20;
size: 14 | 16 | 20 | 24;
symbol: AxoSymbolName;
label: string | null;
}>;

View file

@ -67,7 +67,6 @@ export function ImageOrBlurhash({
backgroundPosition: 'center',
}}
loading={blurHashUrl != null ? 'lazy' : 'eager'}
decoding={blurHashUrl != null ? 'async' : 'auto'}
/>
);
}

View file

@ -14,6 +14,7 @@ import {
VIDEO_MP4,
VIDEO_QUICKTIME,
stringToMIMEType,
type MIMEType,
} from '../types/MIME';
import { fakeAttachment } from '../test-helpers/fakeAttachment';
@ -26,7 +27,11 @@ export default {
args: {},
} satisfies Meta<PropsType>;
type OverridePropsMediaItemType = Partial<MediaItemType> & { caption?: string };
type OverridePropsMediaItemType = Partial<MediaItemType> & {
caption?: string;
objectURL?: string;
contentType?: MIMEType;
};
function createMediaItem(
overrideProps: OverridePropsMediaItemType
@ -34,21 +39,19 @@ function createMediaItem(
return {
attachment: fakeAttachment({
caption: overrideProps.caption || '',
contentType: IMAGE_JPEG,
contentType: overrideProps.contentType ?? IMAGE_JPEG,
fileName: overrideProps.objectURL,
url: overrideProps.objectURL,
}),
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
type: 'incoming',
id: 'image-msg',
receivedAt: 0,
receivedAtMs: Date.now(),
sentAt: Date.now(),
},
objectURL: '',
...overrideProps,
};
}
@ -88,17 +91,15 @@ export function Multimedia(): JSX.Element {
caption:
'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.',
}),
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
type: 'incoming',
id: 'image-msg',
receivedAt: 1,
receivedAtMs: Date.now(),
sentAt: Date.now(),
},
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
},
{
attachment: fakeAttachment({
@ -106,28 +107,24 @@ export function Multimedia(): JSX.Element {
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
}),
contentType: VIDEO_MP4,
index: 1,
message: {
attachments: [],
conversationId: '1234',
type: 'incoming',
id: 'video-msg',
receivedAt: 2,
receivedAtMs: Date.now(),
sentAt: Date.now(),
},
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
},
createMediaItem({
contentType: IMAGE_JPEG,
index: 2,
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
objectURL: '/fixtures/kitten-1-64-64.jpg',
}),
createMediaItem({
contentType: IMAGE_JPEG,
index: 3,
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
objectURL: '/fixtures/kitten-2-64-64.jpg',
}),
],
@ -145,17 +142,15 @@ export function MissingMedia(): JSX.Element {
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
}),
contentType: IMAGE_JPEG,
index: 0,
message: {
attachments: [],
conversationId: '1234',
type: 'incoming',
id: 'image-msg',
receivedAt: 3,
receivedAtMs: Date.now(),
sentAt: Date.now(),
},
objectURL: undefined,
},
],
});

View file

@ -140,13 +140,10 @@ export function Lightbox({
>();
const currentItem = media[selectedIndex];
const {
attachment,
contentType,
loop = false,
objectURL,
incrementalObjectUrl,
} = currentItem || {};
const attachment = currentItem?.attachment;
const url = attachment?.url;
const incrementalUrl = attachment?.incrementalUrl;
const contentType = attachment?.contentType;
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
const isDownloading =
@ -599,7 +596,7 @@ export function Lightbox({
!isVideoTypeSupported && isVideo(contentType);
if (isImageTypeSupported) {
if (objectURL) {
if (url) {
content = (
<div className="Lightbox__zoomable-container">
<button
@ -621,7 +618,7 @@ export function Lightbox({
ev.preventDefault();
}
}}
src={objectURL}
src={url}
ref={imageRef}
/>
</button>
@ -642,19 +639,19 @@ export function Lightbox({
);
}
} else if (isVideoTypeSupported) {
const shouldLoop = loop || isAttachmentGIF || isViewOnce;
const shouldLoop = isAttachmentGIF || isViewOnce;
content = (
<video
className="Lightbox__object Lightbox__object--video"
controls={!shouldLoop}
key={objectURL || incrementalObjectUrl}
key={url || incrementalUrl}
loop={shouldLoop}
ref={setVideoElement}
onMouseMove={onUserInteractionOnVideo}
onMouseLeave={onMouseLeaveVideo}
>
<source src={objectURL || incrementalObjectUrl} />
<source src={url || incrementalUrl} />
</video>
);
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
@ -834,7 +831,7 @@ export function Lightbox({
'Lightbox__thumbnail--selected':
index === selectedIndex,
})}
key={item.thumbnailObjectUrl}
key={item.attachment.thumbnail?.url}
type="button"
onClick={(
event: React.MouseEvent<
@ -848,10 +845,10 @@ export function Lightbox({
onSelectAttachment(index);
}}
>
{item.thumbnailObjectUrl ? (
{item.attachment.thumbnail?.url ? (
<img
alt={i18n('icu:lightboxImageAlt')}
src={item.thumbnailObjectUrl}
src={item.attachment.thumbnail.url}
/>
) : (
<div className="Lightbox__thumbnail--unavailable" />

View file

@ -15,16 +15,13 @@ export default {
args: {
attachment: fakeAttachment(),
isIncoming: false,
renderAttachmentDownloaded: () => {
return <div>🔥🔥</div>;
},
},
} satisfies Meta<PropsType>;
export function Default(args: PropsType): JSX.Element {
return (
<div style={{ backgroundColor: 'gray' }}>
<AttachmentStatusIcon {...args} />
<AttachmentStatusIcon {...args}>🔥🔥</AttachmentStatusIcon>
</div>
);
}
@ -32,7 +29,9 @@ export function Default(args: PropsType): JSX.Element {
export function NoAttachment(args: PropsType): JSX.Element {
return (
<div style={{ backgroundColor: 'gray' }}>
<AttachmentStatusIcon {...args} attachment={undefined} />
<AttachmentStatusIcon {...args} attachment={undefined}>
🔥🔥
</AttachmentStatusIcon>
</div>
);
}
@ -43,7 +42,9 @@ export function NeedsDownload(args: PropsType): JSX.Element {
<AttachmentStatusIcon
{...args}
attachment={fakeAttachment({ path: undefined })}
/>
>
🔥🔥
</AttachmentStatusIcon>
</div>
);
}
@ -59,7 +60,9 @@ export function Downloading(args: PropsType): JSX.Element {
size: 1000000,
totalDownloaded: 750000,
})}
/>
>
🔥🔥
</AttachmentStatusIcon>
</div>
);
}
@ -106,7 +109,9 @@ export function Interactive(args: PropsType): JSX.Element {
<button type="button" onClick={cancelAttachmentDownload}>
stop download
</button>
<AttachmentStatusIcon {...args} attachment={attachment} />
<AttachmentStatusIcon {...args} attachment={attachment}>
🔥🔥
</AttachmentStatusIcon>
</div>
);
}

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState } from 'react';
import React, { useRef, useState, type ReactNode } from 'react';
import classNames from 'classnames';
import { SpinnerV2 } from '../SpinnerV2';
@ -13,9 +13,9 @@ const TRANSITION_DELAY = 200;
export type PropsType = {
attachment: AttachmentForUIType | undefined;
isAttachmentNotAvailable: boolean;
isExpired?: boolean;
isIncoming: boolean;
renderAttachmentDownloaded: () => JSX.Element;
children?: ReactNode;
};
enum IconState {
@ -26,12 +26,18 @@ enum IconState {
export function AttachmentStatusIcon({
attachment,
isAttachmentNotAvailable,
isExpired,
isIncoming,
renderAttachmentDownloaded,
children,
}: PropsType): JSX.Element | null {
const [isWaiting, setIsWaiting] = useState<boolean>(false);
const isAttachmentNotAvailable =
isExpired ||
(attachment != null &&
attachment.isPermanentlyUndownloadable &&
!attachment.wasTooBig);
let state: IconState = IconState.Downloaded;
if (attachment && isAttachmentNotAvailable) {
state = IconState.Downloaded;
@ -159,9 +165,5 @@ export function AttachmentStatusIcon({
);
}
return (
<div className="AttachmentStatusIcon__container">
{renderAttachmentDownloaded()}
</div>
);
return <div className="AttachmentStatusIcon__container">{children}</div>;
}

View file

@ -1367,10 +1367,6 @@ export class Message extends React.PureComponent<Props, State> {
const { fileName, size, contentType } = firstAttachment;
const isIncoming = direction === 'incoming';
const renderAttachmentDownloaded = () => {
return <FileThumbnail contentType={contentType} fileName={fileName} />;
};
const willShowMetadata =
expirationLength || expirationTimestamp || !shouldHideMetadata;
@ -1415,10 +1411,10 @@ export class Message extends React.PureComponent<Props, State> {
<AttachmentStatusIcon
key={id}
attachment={firstAttachment}
isAttachmentNotAvailable={isAttachmentNotAvailable}
isIncoming={isIncoming}
renderAttachmentDownloaded={renderAttachmentDownloaded}
/>
>
<FileThumbnail contentType={contentType} fileName={fileName} />
</AttachmentStatusIcon>
<div className="module-message__simple-attachment__text">
<div
className={classNames(
@ -2852,10 +2848,11 @@ export class Message extends React.PureComponent<Props, State> {
<AttachmentStatusIcon
key={id}
attachment={firstAttachment}
isAttachmentNotAvailable={isExpired}
isExpired={isExpired}
isIncoming={isIncoming}
renderAttachmentDownloaded={() => this.renderTapToViewIcon()}
/>
>
{this.renderTapToViewIcon()}
</AttachmentStatusIcon>
{content}
</div>
);

View file

@ -28,31 +28,24 @@ export function renderAvatar({
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
const title = getName(contact) || '';
const isAttachmentNotAvailable = Boolean(
avatar?.avatar?.isPermanentlyUndownloadable
);
const renderAttachmentDownloaded = () => (
<Avatar
avatarUrl={avatarUrl}
badge={undefined}
blur={AvatarBlur.NoBlur}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
title={title}
sharedGroupNames={[]}
size={size}
/>
);
return (
<AttachmentStatusIcon
attachment={avatar?.avatar}
isAttachmentNotAvailable={isAttachmentNotAvailable}
isIncoming={direction === 'incoming'}
renderAttachmentDownloaded={renderAttachmentDownloaded}
/>
>
<Avatar
avatarUrl={avatarUrl}
badge={undefined}
blur={AvatarBlur.NoBlur}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
title={title}
sharedGroupNames={[]}
size={size}
/>
</AttachmentStatusIcon>
);
}

View file

@ -65,7 +65,7 @@ const createProps = (
isGroup: true,
isSignalConversation: false,
leaveGroup: action('leaveGroup'),
loadRecentMediaItems: action('loadRecentMediaItems'),
hasMedia: true,
memberships: times(32, i => ({
isAdmin: i === 1,
member: getDefaultConversation({
@ -86,7 +86,6 @@ const createProps = (
showContactModal: action('showContactModal'),
pushPanelForConversation: action('pushPanelForConversation'),
showConversation: action('showConversation'),
showLightbox: action('showLightbox'),
startAvatarDownload: action('startAvatarDownload'),
updateGroupAttributes: async () => {
action('updateGroupAttributes')();

View file

@ -29,8 +29,6 @@ import { AddGroupMembersModal } from './AddGroupMembersModal';
import { ConversationDetailsActions } from './ConversationDetailsActions';
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
import type { Props as ConversationDetailsMediaListPropsType } from './ConversationDetailsMediaList';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import type { GroupV2Membership } from './ConversationDetailsMembershipList';
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
import type {
@ -82,6 +80,7 @@ export type StateProps = {
canAddNewMembers: boolean;
conversation?: ConversationType;
hasGroupLink: boolean;
hasMedia: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
hasActiveCall: boolean;
i18n: LocalizerType;
@ -122,7 +121,6 @@ type ActionProps = {
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void;
loadRecentMediaItems: (id: string, limit: number) => void;
onDeleteNicknameAndNote: () => void;
onOpenEditNicknameAndNoteModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
@ -150,7 +148,7 @@ type ActionProps = {
onFailure?: () => unknown;
}
) => unknown;
} & Pick<ConversationDetailsMediaListPropsType, 'showLightbox'>;
};
export type Props = StateProps & ActionProps;
@ -180,6 +178,7 @@ export function ConversationDetails({
conversation,
deleteAvatarFromDisk,
hasGroupLink,
hasMedia,
getPreferredBadge,
getProfilesForConversation,
groupsInCommon,
@ -189,7 +188,6 @@ export function ConversationDetails({
isGroup,
isSignalConversation,
leaveGroup,
loadRecentMediaItems,
memberships,
maxGroupSize,
maxRecommendedGroupSize,
@ -211,7 +209,6 @@ export function ConversationDetails({
setMuteExpiration,
showContactModal,
showConversation,
showLightbox,
startAvatarDownload,
theme,
toggleAboutContactModal,
@ -691,6 +688,22 @@ export function ConversationDetails({
}
/>
)}
{hasMedia && (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetailsMediaList--title')}
icon={IconType.media}
/>
}
label={i18n('icu:ConversationDetailsMediaList--title')}
onClick={() => {
pushPanelForConversation({
type: PanelType.AllMedia,
});
}}
/>
)}
{!isGroup && !conversation.isMe && (
<PanelRow
onClick={() => toggleSafetyNumberModal(conversation.id)}
@ -779,18 +792,6 @@ export function ConversationDetails({
</PanelSection>
)}
<ConversationDetailsMediaList
conversation={conversation}
i18n={i18n}
loadRecentMediaItems={loadRecentMediaItems}
showAllMedia={() =>
pushPanelForConversation({
type: PanelType.AllMedia,
})
}
showLightbox={showLightbox}
/>
{!isGroup && !conversation.isMe && !isSignalConversation && (
<ConversationDetailsGroups
contactId={conversation.id}

View file

@ -23,6 +23,7 @@ export enum IconType {
'leave' = 'leave',
'link' = 'link',
'lock' = 'lock',
'media' = 'media',
'mention' = 'mention',
'mute' = 'mute',
'notifications' = 'notifications',

View file

@ -1,37 +0,0 @@
// Copyright 2020 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 './ConversationDetailsMediaList';
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
import type { MediaItemType } from '../../../types/MediaItem';
import { getDefaultConversation } from '../../../test-helpers/getDefaultConversation';
import {
createPreparedMediaItems,
createRandomMedia,
} from '../media-gallery/utils/mocks';
const { i18n } = window.SignalContext;
export default {
title: 'Components/Conversation/ConversationDetails/ConversationMediaList',
} satisfies Meta<Props>;
const createProps = (mediaItems?: Array<MediaItemType>): Props => ({
conversation: getDefaultConversation({
recentMediaItems: mediaItems || [],
}),
i18n,
loadRecentMediaItems: action('loadRecentMediaItems'),
showAllMedia: action('showAllMedia'),
showLightbox: action('showLightbox'),
});
export function Basic(): JSX.Element {
const mediaItems = createPreparedMediaItems(createRandomMedia);
const props = createProps(mediaItems);
return <ConversationDetailsMediaList {...props} />;
}

View file

@ -1,78 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../../../types/Util';
import type { ConversationType } from '../../../state/ducks/conversations';
import type { AttachmentType } from '../../../types/Attachment';
import { PanelSection } from './PanelSection';
import { bemGenerator } from './util';
import { MediaGridItem } from '../media-gallery/MediaGridItem';
export type Props = {
conversation: ConversationType;
i18n: LocalizerType;
loadRecentMediaItems: (id: string, limit: number) => void;
showAllMedia: () => void;
showLightbox: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
};
const MEDIA_ITEM_LIMIT = 6;
const bem = bemGenerator('ConversationDetails-media-list');
export function ConversationDetailsMediaList({
conversation,
i18n,
loadRecentMediaItems,
showAllMedia,
showLightbox,
}: Props): JSX.Element | null {
const mediaItems = conversation.recentMediaItems || [];
const mediaItemsLength = mediaItems.length;
React.useEffect(() => {
loadRecentMediaItems(conversation.id, MEDIA_ITEM_LIMIT);
}, [conversation.id, loadRecentMediaItems, mediaItemsLength]);
if (mediaItemsLength === 0) {
return null;
}
return (
<PanelSection
actions={
<button
className={bem('show-all')}
onClick={showAllMedia}
type="button"
>
{i18n('icu:ConversationDetailsMediaList--show-all')}
</button>
}
title={i18n('icu:ConversationDetailsMediaList--shared-media')}
>
<div className={bem('root')}>
{mediaItems.slice(0, MEDIA_ITEM_LIMIT).map(mediaItem => (
<MediaGridItem
key={`${mediaItem.message.id}-${mediaItem.index}`}
mediaItem={mediaItem}
i18n={i18n}
onClick={() =>
showLightbox({
attachment: mediaItem.attachment,
messageId: mediaItem.message.id,
})
}
/>
))}
</div>
</PanelSection>
);
}

View file

@ -4,11 +4,12 @@
import React from 'react';
import type { ItemClickEvent } from './types/ItemClickEvent';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem';
import { DocumentListItem } from './DocumentListItem';
import { MediaGridItem } from './MediaGridItem';
import { missingCaseError } from '../../../util/missingCaseError';
import { tw } from '../../../axo/tw';
export type Props = {
header?: string;
@ -16,6 +17,7 @@ export type Props = {
mediaItems: ReadonlyArray<MediaItemType>;
onItemClick: (event: ItemClickEvent) => unknown;
type: 'media' | 'documents';
theme?: ThemeType;
};
export function AttachmentSection({
@ -24,45 +26,66 @@ export function AttachmentSection({
type,
mediaItems,
onItemClick,
theme,
}: Props): JSX.Element {
return (
<div className="module-attachment-section">
<h2 className="module-attachment-section__header">{header}</h2>
<div className="module-attachment-section__items">
{mediaItems.map((mediaItem, position, array) => {
const shouldShowSeparator = position < array.length - 1;
const { message, index, attachment } = mediaItem;
switch (type) {
case 'media':
return (
<section className={tw('ps-5')}>
<h2 className={tw('ps-1 pt-4 pb-2 font-semibold')}>{header}</h2>
<div className={tw('flex flex-row flex-wrap gap-1 pb-1')}>
{mediaItems.map(mediaItem => {
const { message, index, attachment } = mediaItem;
const onClick = () => {
onItemClick({ type, message, attachment });
};
const onClick = (ev: React.MouseEvent) => {
ev.preventDefault();
onItemClick({ type, message, attachment });
};
switch (type) {
case 'media':
return (
<MediaGridItem
key={`${message.id}-${index}`}
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
theme={theme}
/>
);
case 'documents':
})}
</div>
</section>
);
case 'documents':
return (
<section
className={tw(
'px-6',
'mb-3 border-b border-b-border-primary pb-3',
'last:mb-0 last:border-b-0 last:pb-0'
)}
>
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
<div>
{mediaItems.map(mediaItem => {
const { message, index, attachment } = mediaItem;
const onClick = (ev: React.MouseEvent) => {
ev.preventDefault();
onItemClick({ type, message, attachment });
};
return (
<DocumentListItem
key={`${message.id}-${index}`}
fileName={attachment.fileName}
fileSize={attachment.size}
shouldShowSeparator={shouldShowSeparator}
mediaItem={mediaItem}
onClick={onClick}
timestamp={message.receivedAtMs || message.receivedAt}
/>
);
default:
throw missingCaseError(type);
}
})}
</div>
</div>
);
})}
</div>
</section>
);
default:
throw missingCaseError(type);
}
}

View file

@ -6,55 +6,22 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { Props } from './DocumentListItem';
import { DocumentListItem } from './DocumentListItem';
import { createPreparedMediaItems, createRandomDocuments } from './utils/mocks';
export default {
title: 'Components/Conversation/MediaGallery/DocumentListItem',
argTypes: {
timestamp: { control: { type: 'date' } },
fileName: { control: { type: 'text' } },
fileSize: { control: { type: 'number' } },
shouldShowSeparator: { control: { type: 'boolean' } },
},
args: {
timestamp: Date.now(),
fileName: 'meow.jpg',
fileSize: 1024 * 1000 * 2,
shouldShowSeparator: false,
onClick: action('onClick'),
},
} satisfies Meta<Props>;
export function Single(args: Props): JSX.Element {
return <DocumentListItem {...args} />;
}
export function Multiple(): JSX.Element {
const items = [
{
fileName: 'meow.jpg',
fileSize: 1024 * 1000 * 2,
timestamp: Date.now(),
},
{
fileName: 'rickroll.mp4',
fileSize: 1024 * 1000 * 8,
timestamp: Date.now() - 24 * 60 * 60 * 1000,
},
{
fileName: 'kitten.gif',
fileSize: 1024 * 1000 * 1.2,
timestamp: Date.now() - 14 * 24 * 60 * 60 * 1000,
shouldShowSeparator: false,
},
];
const items = createPreparedMediaItems(createRandomDocuments);
return (
<>
{items.map(item => (
{items.map(mediaItem => (
<DocumentListItem
key={item.fileName}
key={mediaItem.attachment.fileName}
mediaItem={mediaItem}
onClick={action('onClick')}
{...item}
/>
))}
</>

View file

@ -2,54 +2,46 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { formatFileSize } from '../../../util/formatFileSize';
import type { MediaItemType } from '../../../types/MediaItem';
import { tw } from '../../../axo/tw';
import { FileThumbnail } from '../../FileThumbnail';
export type Props = {
// Required
timestamp: number;
mediaItem: MediaItemType;
// Optional
fileName?: string;
fileSize?: number;
onClick?: () => void;
shouldShowSeparator?: boolean;
onClick?: (ev: React.MouseEvent) => void;
};
export function DocumentListItem({
shouldShowSeparator = true,
fileName,
fileSize,
onClick,
timestamp,
}: Props): JSX.Element {
export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element {
const { attachment, message } = mediaItem;
const { fileName, size: fileSize } = attachment;
const timestamp = message.receivedAtMs || message.receivedAt;
return (
<div
className={classNames(
'module-document-list-item',
shouldShowSeparator ? 'module-document-list-item--with-separator' : null
)}
<button
className={tw('flex w-full flex-row items-center gap-3 py-2')}
type="button"
onClick={onClick}
>
<button
type="button"
className="module-document-list-item__content"
onClick={onClick}
>
<div className="module-document-list-item__icon" />
<div className="module-document-list-item__metadata">
<span className="module-document-list-item__file-name">
{fileName}
</span>
<span className="module-document-list-item__file-size">
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
</span>
<div className={tw('shrink-0')}>
<FileThumbnail {...attachment} />
</div>
<div className={tw('grow overflow-hidden text-left')}>
<h3 className={tw('truncate')}>{fileName}</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
</div>
<div className="module-document-list-item__date">
{moment(timestamp).format('ddd, MMM D, Y')}
</div>
</button>
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
</button>
);
}

View file

@ -34,10 +34,15 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
loadMoreMedia: action('loadMoreMedia'),
saveAttachment: action('saveAttachment'),
showLightbox: action('showLightbox'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
});
export function Populated(): JSX.Element {
const documents = createRandomDocuments(Date.now(), days(1)).slice(0, 1);
const documents = createRandomDocuments(Date.now() - days(5), days(5)).slice(
0,
10
);
const media = createPreparedMediaItems(createRandomMedia);
const props = createProps({ documents, media });

View file

@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react';
import moment from 'moment';
import type { ItemClickEvent } from './types/ItemClickEvent';
import type { LocalizerType } from '../../../types/Util';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem';
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations';
import { AttachmentSection } from './AttachmentSection';
@ -34,10 +34,13 @@ export type Props = {
loadMoreDocuments: (id: string) => unknown;
media: ReadonlyArray<MediaItemType>;
saveAttachment: SaveAttachmentActionCreatorType;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
showLightbox: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
theme?: ThemeType;
};
const MONTH_FORMAT = 'MMMM YYYY';
@ -48,11 +51,22 @@ function MediaSection({
loading,
media,
saveAttachment,
kickOffAttachmentDownload,
cancelAttachmentDownload,
showLightbox,
type,
theme,
}: Pick<
Props,
'documents' | 'i18n' | 'loading' | 'media' | 'saveAttachment' | 'showLightbox'
| 'documents'
| 'i18n'
| 'theme'
| 'loading'
| 'media'
| 'saveAttachment'
| 'kickOffAttachmentDownload'
| 'cancelAttachmentDownload'
| 'showLightbox'
> & { type: 'media' | 'documents' }): JSX.Element {
const mediaItems = type === 'media' ? media : documents;
@ -107,6 +121,7 @@ function MediaSection({
key={header}
header={header}
i18n={i18n}
theme={theme}
type={type}
mediaItems={section.mediaItems}
onItemClick={(event: ItemClickEvent) => {
@ -117,10 +132,16 @@ function MediaSection({
}
case 'media': {
showLightbox({
attachment: event.attachment,
messageId: event.message.id,
});
if (event.attachment.url || event.attachment.incrementalUrl) {
showLightbox({
attachment: event.attachment,
messageId: event.message.id,
});
} else if (event.attachment.pending) {
cancelAttachmentDownload({ messageId: event.message.id });
} else {
kickOffAttachmentDownload({ messageId: event.message.id });
}
break;
}
@ -147,6 +168,8 @@ export function MediaGallery({
loadMoreMedia,
media,
saveAttachment,
kickOffAttachmentDownload,
cancelAttachmentDownload,
showLightbox,
}: Props): JSX.Element {
const focusRef = useRef<HTMLDivElement | null>(null);
@ -264,6 +287,8 @@ export function MediaGallery({
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
type="media"
/>
)}
@ -275,6 +300,8 @@ export function MediaGallery({
media={media}
saveAttachment={saveAttachment}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
type="documents"
/>
)}

View file

@ -4,9 +4,15 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext';
import type { MediaItemType } from '../../../types/MediaItem';
import type { AttachmentType } from '../../../types/Attachment';
import { stringToMIMEType } from '../../../types/MIME';
import { SignalService } from '../../../protobuf';
import {
IMAGE_JPEG,
VIDEO_MP4,
APPLICATION_OCTET_STREAM,
type MIMEType,
} from '../../../types/MIME';
import type { Props } from './MediaGridItem';
import { MediaGridItem } from './MediaGridItem';
@ -18,21 +24,37 @@ export default {
const createProps = (
overrideProps: Partial<Props> & { mediaItem: MediaItemType }
): Props => ({
i18n,
mediaItem: overrideProps.mediaItem,
onClick: action('onClick'),
});
): Props => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const theme = React.useContext(StorybookThemeContext);
return {
i18n,
theme,
mediaItem: overrideProps.mediaItem,
onClick: action('onClick'),
};
};
type OverridePropsMediaItemType = Partial<MediaItemType> & {
objectURL?: string;
contentType?: MIMEType;
};
const createMediaItem = (
overrideProps: Partial<MediaItemType> = {}
overrideProps: OverridePropsMediaItemType
): MediaItemType => ({
thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '',
contentType: overrideProps.contentType || stringToMIMEType(''),
index: 0,
attachment: {} as AttachmentType, // attachment not useful in the component
attachment: overrideProps.attachment || {
path: '123',
contentType: overrideProps.contentType ?? IMAGE_JPEG,
size: 123,
url: overrideProps.objectURL,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
isPermanentlyUndownloadable: false,
},
message: {
attachments: [],
type: 'incoming',
conversationId: '1234',
id: 'id',
receivedAt: Date.now(),
@ -43,8 +65,34 @@ const createMediaItem = (
export function Image(): JSX.Element {
const mediaItem = createMediaItem({
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
contentType: stringToMIMEType('image/jpeg'),
objectURL: '/fixtures/kitten-1-64-64.jpg',
contentType: IMAGE_JPEG,
});
const props = createProps({
mediaItem,
});
return <MediaGridItem {...props} />;
}
export function WideImage(): JSX.Element {
const mediaItem = createMediaItem({
objectURL: '/fixtures/wide.jpg',
contentType: IMAGE_JPEG,
});
const props = createProps({
mediaItem,
});
return <MediaGridItem {...props} />;
}
export function TallImage(): JSX.Element {
const mediaItem = createMediaItem({
objectURL: '/fixtures/snow.jpg',
contentType: IMAGE_JPEG,
});
const props = createProps({
@ -56,8 +104,42 @@ export function Image(): JSX.Element {
export function Video(): JSX.Element {
const mediaItem = createMediaItem({
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
contentType: stringToMIMEType('video/mp4'),
attachment: {
incrementalUrl: 'abc',
screenshot: {
url: '/fixtures/kitten-2-64-64.jpg',
contentType: IMAGE_JPEG,
},
contentType: VIDEO_MP4,
size: 1024,
isPermanentlyUndownloadable: false,
path: 'abcd',
},
contentType: VIDEO_MP4,
});
const props = createProps({
mediaItem,
});
return <MediaGridItem {...props} />;
}
export function GIF(): JSX.Element {
const mediaItem = createMediaItem({
attachment: {
url: 'abc',
screenshot: {
url: '/fixtures/kitten-2-64-64.jpg',
contentType: IMAGE_JPEG,
},
contentType: VIDEO_MP4,
size: 1024,
isPermanentlyUndownloadable: false,
path: 'abcd',
flags: SignalService.AttachmentPointer.Flags.GIF,
},
contentType: VIDEO_MP4,
});
const props = createProps({
@ -69,7 +151,53 @@ export function Video(): JSX.Element {
export function MissingImage(): JSX.Element {
const mediaItem = createMediaItem({
contentType: stringToMIMEType('image/jpeg'),
contentType: IMAGE_JPEG,
attachment: {
contentType: IMAGE_JPEG,
size: 123,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
isPermanentlyUndownloadable: false,
},
});
const props = createProps({
mediaItem,
});
return <MediaGridItem {...props} />;
}
export function PendingImage(): JSX.Element {
const mediaItem = createMediaItem({
contentType: IMAGE_JPEG,
attachment: {
contentType: IMAGE_JPEG,
size: 123000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
isPermanentlyUndownloadable: false,
totalDownloaded: 0,
pending: true,
},
});
const props = createProps({
mediaItem,
});
return <MediaGridItem {...props} />;
}
export function DownloadingImage(): JSX.Element {
const mediaItem = createMediaItem({
contentType: IMAGE_JPEG,
attachment: {
contentType: IMAGE_JPEG,
size: 123000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
isPermanentlyUndownloadable: false,
totalDownloaded: 20300,
pending: true,
},
});
const props = createProps({
@ -81,7 +209,7 @@ export function MissingImage(): JSX.Element {
export function MissingVideo(): JSX.Element {
const mediaItem = createMediaItem({
contentType: stringToMIMEType('video/mp4'),
contentType: VIDEO_MP4,
});
const props = createProps({
@ -93,8 +221,8 @@ export function MissingVideo(): JSX.Element {
export function BrokenImage(): JSX.Element {
const mediaItem = createMediaItem({
thumbnailObjectUrl: '/missing-fixtures/nope.jpg',
contentType: stringToMIMEType('image/jpeg'),
objectURL: '/missing-fixtures/nope.jpg',
contentType: IMAGE_JPEG,
});
const props = createProps({
@ -106,8 +234,8 @@ export function BrokenImage(): JSX.Element {
export function BrokenVideo(): JSX.Element {
const mediaItem = createMediaItem({
thumbnailObjectUrl: '/missing-fixtures/nope.mp4',
contentType: stringToMIMEType('video/mp4'),
objectURL: '/missing-fixtures/nope.mp4',
contentType: VIDEO_MP4,
});
const props = createProps({
@ -119,7 +247,7 @@ export function BrokenVideo(): JSX.Element {
export function OtherContentType(): JSX.Element {
const mediaItem = createMediaItem({
contentType: stringToMIMEType('application/text'),
contentType: APPLICATION_OCTET_STREAM,
});
const props = createProps({

View file

@ -1,105 +1,167 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import React from 'react';
import type { ReadonlyDeep } from 'type-fest';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../../util/GoogleChrome';
import type { LocalizerType } from '../../../types/Util';
import { formatFileSize } from '../../../util/formatFileSize';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem';
import { createLogger } from '../../../logging/log';
import type { AttachmentForUIType } from '../../../types/Attachment';
import {
getAlt,
getUrl,
defaultBlurHash,
isGIF,
} from '../../../types/Attachment';
import { ImageOrBlurhash } from '../../ImageOrBlurhash';
import { SpinnerV2 } from '../../SpinnerV2';
import { tw } from '../../../axo/tw';
import { AxoSymbol } from '../../../axo/AxoSymbol';
const log = createLogger('MediaGridItem');
export type Props = {
export type Props = Readonly<{
mediaItem: ReadonlyDeep<MediaItemType>;
onClick?: () => void;
onClick?: (ev: React.MouseEvent) => void;
i18n: LocalizerType;
};
theme?: ThemeType;
}>;
function MediaGridItemContent(props: Props) {
const { mediaItem, i18n } = props;
const { attachment, contentType } = mediaItem;
export function MediaGridItem(props: Props): JSX.Element {
const {
mediaItem: { attachment },
i18n,
theme,
onClick,
} = props;
const [imageBroken, setImageBroken] = useState(false);
const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
const url = getUrl(attachment);
const handleImageError = useCallback(() => {
log.info('Image failed to load; failing over to placeholder');
setImageBroken(true);
}, []);
const { width, height } = attachment;
if (!attachment) {
return null;
const imageOrBlurHash = (
<ImageOrBlurhash
className={tw('object-cover')}
src={url}
intrinsicWidth={width}
intrinsicHeight={height}
alt={getAlt(attachment, i18n)}
blurHash={resolvedBlurHash}
/>
);
let label: string;
if (attachment.url || attachment.incrementalUrl) {
label = i18n('icu:imageOpenAlt');
} else if (attachment.pending) {
label = i18n('icu:cancelDownload');
} else {
label = i18n('icu:startDownload');
}
if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-image'
)}
/>
);
}
return (
<button
type="button"
className={tw(
'relative size-30 overflow-hidden rounded-md',
'flex items-center justify-center'
)}
onClick={onClick}
aria-label={label}
>
{imageOrBlurHash}
return (
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={handleImageError}
/>
);
<MetadataOverlay i18n={i18n} attachment={attachment} />
<SpinnerOverlay attachment={attachment} />
</button>
);
}
type SpinnerOverlayProps = Readonly<{
attachment: AttachmentForUIType;
}>;
function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
const { attachment } = props;
if (attachment.url != null || attachment.incrementalUrl != null) {
return undefined;
}
if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
const spinnerValue =
(!attachment.incrementalUrl &&
attachment.size &&
attachment.totalDownloaded) ||
undefined;
return (
<div className="module-media-grid-item__image-container">
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={handleImageError}
return (
<div
className={tw(
'absolute size-12.5 rounded-full bg-fill-on-media',
'flex items-center justify-center'
)}
>
{attachment.pending && (
<SpinnerV2
variant="no-background"
size={44}
strokeWidth={2}
marginRatio={1}
min={0}
max={attachment.size}
value={spinnerValue}
/>
)}
<div className={tw('absolute text-label-primary-on-color')}>
<AxoSymbol.Icon
symbol={attachment.pending ? 'x' : 'arrow-down'}
size={24}
label={null}
/>
<div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" />
</div>
</div>
);
</div>
);
}
type MetadataOverlayProps = Readonly<{
i18n: LocalizerType;
attachment: AttachmentForUIType;
}>;
function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined {
const { i18n, attachment } = props;
const canBeShown =
attachment.url != null || attachment.incrementalUrl != null;
if (canBeShown && !isGIF([attachment])) {
return undefined;
}
let text: string;
if (isGIF([attachment]) && canBeShown) {
text = i18n('icu:message--getNotificationText--gif');
} else {
text = formatFileSize(attachment.size);
}
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-generic'
className={tw(
'absolute end-0 bottom-0 h-11.5 w-full',
// This is an overlay gradient to ensure that the text has contrast
// against the image/blurhash.
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
'bg-linear-to-b from-transparent to-[rgba(0,0,0,0.6)]'
)}
/>
);
}
export function MediaGridItem(props: Props): JSX.Element {
const { onClick } = props;
return (
<button type="button" className="module-media-grid-item" onClick={onClick}>
<MediaGridItemContent {...props} />
</button>
>
<span
className={tw(
'absolute end-2 bottom-1.5',
'type-caption text-[12px] text-label-primary-on-color'
)}
>
{text}
</span>
</div>
);
}

View file

@ -2,8 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { random, range, sample, sortBy } from 'lodash';
import type { MIMEType } from '../../../../types/MIME';
import { type MIMEType, IMAGE_JPEG } from '../../../../types/MIME';
import type { MediaItemType } from '../../../../types/MediaItem';
import { randomBlurHash } from '../../../../util/randomBlurHash';
import { SignalService } from '../../../../protobuf';
const DAY_MS = 24 * 60 * 60 * 1000;
export const days = (n: number): number => n * DAY_MS;
@ -16,6 +18,7 @@ const contentTypes = {
mp4: 'video/mp4',
docx: 'application/text',
pdf: 'application/pdf',
exe: 'application/exe',
txt: 'application/text',
} as unknown as Record<string, MIMEType>;
@ -27,27 +30,42 @@ function createRandomFile(
const contentType = contentTypes[fileExtension];
const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`;
const isDownloaded = Math.random() > 0.4;
return {
contentType,
message: {
conversationId: '123',
type: 'incoming',
id: random(Date.now()).toString(),
receivedAt: Math.floor(Math.random() * 10),
receivedAtMs: random(startTime, startTime + timeWindow),
attachments: [],
sentAt: Date.now(),
},
attachment: {
url: '',
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
path: isDownloaded ? 'abc' : undefined,
screenshot:
fileExtension === 'mp4'
? {
url: isDownloaded
? '/fixtures/cat-screenshot-3x4.png'
: undefined,
contentType: IMAGE_JPEG,
}
: undefined,
flags:
fileExtension === 'mp4' && Math.random() > 0.5
? SignalService.AttachmentPointer.Flags.GIF
: 0,
width: 400,
height: 300,
fileName,
size: random(1000, 1000 * 1000 * 50),
contentType,
blurHash: randomBlurHash(),
isPermanentlyUndownloadable: false,
},
index: 0,
thumbnailObjectUrl: `https://placekitten.com/${random(50, 150)}/${random(
50,
150
)}`,
};
}
@ -64,7 +82,12 @@ export function createRandomDocuments(
startTime: number,
timeWindow: number
): Array<MediaItemType> {
return createRandomFiles(startTime, timeWindow, ['docx', 'pdf', 'txt']);
return createRandomFiles(startTime, timeWindow, [
'docx',
'pdf',
'exe',
'txt',
]);
}
export function createRandomMedia(

View file

@ -50,6 +50,8 @@ import type {
} from '../types/GroupSendEndorsements';
import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { AttachmentType } from '../types/Attachment';
import type { MediaItemMessageType } from '../types/MediaItem';
import type { GifType } from '../components/fun/panels/FunPanelGifs';
import type { NotificationProfileType } from '../types/NotificationProfile';
import type { DonationReceipt } from '../types/Donations';
@ -566,9 +568,26 @@ export type BackupAttachmentDownloadProgress = {
completedBytes: number;
};
export type GetOlderMediaOptionsType = Readonly<{
conversationId: string;
limit: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
}>;
export type MediaItemDBType = Readonly<{
attachment: AttachmentType;
index: number;
message: MediaItemMessageType;
}>;
export const MESSAGE_ATTACHMENT_COLUMNS = [
'messageId',
'conversationId',
'messageType',
'receivedAt',
'receivedAtMs',
'sentAt',
'attachmentType',
'orderInMessage',
@ -612,6 +631,7 @@ export const MESSAGE_ATTACHMENT_COLUMNS = [
'storyTextAttachmentJson',
'localBackupPath',
'isCorrupted',
'isViewOnce',
'backfillError',
'error',
'wasTooBig',
@ -626,6 +646,9 @@ export type MessageAttachmentDBType = {
orderInMessage: number;
editHistoryIndex: number | null;
conversationId: string;
messageType: string;
receivedAt: number;
receivedAtMs: number | null;
sentAt: number;
clientUuid: string | null;
size: number;
@ -667,6 +690,7 @@ export type MessageAttachmentDBType = {
storyTextAttachmentJson: string | null;
localBackupPath: string | null;
isCorrupted: 1 | 0 | null;
isViewOnce: 1 | 0 | null;
backfillError: 1 | 0 | null;
error: 1 | 0 | null;
wasTooBig: 1 | 0 | null;
@ -766,6 +790,8 @@ type ReadableInterface = {
maxTimestamp: number
) => Array<MessageType>;
// getOlderMessagesByConversation is JSON on server, full message on Client
hasMedia: (conversationId: string) => boolean;
getOlderMedia: (options: GetOlderMediaOptionsType) => Array<MediaItemDBType>;
getAllStories: (options: {
conversationId?: string;
sourceServiceId?: ServiceIdString;

View file

@ -88,6 +88,7 @@ import {
import {
hydrateMessage,
hydrateMessages,
convertAttachmentDBFieldsToAttachmentType,
getAttachmentReferencesForMessages,
ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
} from './hydration';
@ -148,10 +149,12 @@ import type {
GetConversationRangeCenteredOnMessageResultType,
GetKnownMessageAttachmentsResultType,
GetNearbyMessageFromDeletedSetOptionsType,
GetOlderMediaOptionsType,
GetRecentStoryRepliesOptionsType,
GetUnreadByConversationAndMarkReadResultType,
IdentityKeyIdType,
ItemKeyType,
MediaItemDBType,
MessageAttachmentsCursorType,
MessageCursorType,
MessageMetricsType,
@ -434,6 +437,9 @@ export const DataReader: ServerReadableInterface = {
getCallHistoryGroups,
hasGroupCallHistoryMessage,
hasMedia,
getOlderMedia,
getAllNotificationProfiles,
getNotificationProfileById,
@ -2481,17 +2487,29 @@ function saveMessageAttachmentsForRootOrEditedVersion(
conversationId: string;
sent_at: number;
} & Pick<
MessageAttributesType,
MessageType,
| 'attachments'
| 'bodyAttachment'
| 'contact'
| 'preview'
| 'quote'
| 'type'
| 'sticker'
| 'isViewOnce'
| 'received_at'
| 'received_at_ms'
>,
{ editHistoryIndex }: { editHistoryIndex: number | null }
) {
const { id: messageId, conversationId, sent_at: sentAt } = message;
const {
id: messageId,
type: messageType,
conversationId,
sent_at: sentAt,
received_at: receivedAt,
received_at_ms: receivedAtMs,
isViewOnce,
} = message;
const mainAttachments = message.attachments;
if (mainAttachments) {
@ -2500,12 +2518,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'attachment',
attachment,
orderInMessage: i,
editHistoryIndex,
isViewOnce,
});
}
}
@ -2515,12 +2537,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'long-message',
attachment: bodyAttachment,
orderInMessage: 0,
editHistoryIndex,
isViewOnce,
});
}
@ -2534,12 +2560,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'preview',
attachment,
orderInMessage: i,
editHistoryIndex,
isViewOnce,
});
}
}
@ -2554,12 +2584,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'quote',
attachment: attachment.thumbnail,
orderInMessage: i,
editHistoryIndex,
isViewOnce,
});
}
}
@ -2576,12 +2610,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'contact',
attachment,
orderInMessage: i,
editHistoryIndex,
isViewOnce,
});
}
}
@ -2591,12 +2629,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'sticker',
attachment: stickerAttachment,
orderInMessage: 0,
editHistoryIndex,
isViewOnce,
});
}
}
@ -2620,8 +2662,10 @@ function saveMessageAttachments(
db,
{
id: message.id,
type: message.type,
conversationId: message.conversationId,
sent_at: editHistory.timestamp,
isViewOnce: message.isViewOnce,
...editHistory,
},
{ editHistoryIndex: idx }
@ -2632,30 +2676,41 @@ function saveMessageAttachments(
function saveMessageAttachment({
db,
messageId,
messageType,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
attachmentType,
attachment,
orderInMessage,
editHistoryIndex,
isViewOnce,
}: {
db: WritableDB;
messageId: string;
messageType: string;
conversationId: string;
sentAt: number;
receivedAt: number;
receivedAtMs: number | undefined;
attachmentType: AttachmentDownloadJobTypeType;
attachment: AttachmentType;
orderInMessage: number;
editHistoryIndex: number | null;
isViewOnce: boolean | undefined;
}) {
const unparsedValues: ShallowNullToUndefined<MessageAttachmentDBType> = {
messageId,
messageType,
editHistoryIndex:
editHistoryIndex ?? ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
attachmentType,
orderInMessage,
conversationId,
sentAt,
receivedAt,
receivedAtMs,
clientUuid: attachment.clientUuid,
size: attachment.size,
contentType: attachment.contentType,
@ -2700,6 +2755,7 @@ function saveMessageAttachment({
wasTooBig: convertOptionalBooleanToInteger(attachment.wasTooBig),
backfillError: convertOptionalBooleanToInteger(attachment.backfillError),
isCorrupted: convertOptionalBooleanToInteger(attachment.isCorrupted),
isViewOnce: convertOptionalBooleanToInteger(isViewOnce),
copiedFromQuotedAttachment:
'copied' in attachment
? convertOptionalBooleanToInteger(attachment.copied)
@ -5005,6 +5061,94 @@ function hasGroupCallHistoryMessage(
return exists === 1;
}
function hasMedia(db: ReadableDB, conversationId: string): boolean {
const [query, params] = sql`
SELECT EXISTS(
SELECT 1 FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
WHERE
conversationId IS ${conversationId} AND
editHistoryIndex IS -1 AND
attachmentType IS 'attachment' AND
messageType IN ('incoming', 'outgoing') AND
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/%'
);
`;
const exists = db.prepare(query, { pluck: true }).get<number>(params);
return exists === 1;
}
function getOlderMedia(
db: ReadableDB,
{
conversationId,
limit,
messageId,
receivedAt: maxReceivedAt = Number.MAX_VALUE,
sentAt: maxSentAt = Number.MAX_VALUE,
}: GetOlderMediaOptionsType
): Array<MediaItemDBType> {
const timeFilters = {
first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`,
second: sqlFragment`receivedAt < ${maxReceivedAt}`,
};
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
SELECT
*
FROM message_attachments
INDEXED BY message_attachments_getOlderMedia
WHERE
conversationId IS ${conversationId} AND
editHistoryIndex IS -1 AND
attachmentType IS 'attachment' AND
(
${timeFilter}
) AND
(
-- see 'isVisualMedia' in ts/types/Attachment.ts
contentType LIKE 'image/%' OR
contentType LIKE 'video/%'
) AND
isViewOnce IS NOT 1 AND
messageType IN ('incoming', 'outgoing') AND
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
ORDER BY receivedAt DESC, sentAt DESC
LIMIT ${limit}
`;
const [query, params] = sql`
SELECT first.* FROM (${createQuery(timeFilters.first)}) as first
UNION ALL
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
`;
const results: Array<MessageAttachmentDBType> = db.prepare(query).all(params);
return results.map(attachment => {
const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } =
attachment;
return {
message: {
id: attachment.messageId,
type: messageType as 'incoming' | 'outgoing',
conversationId,
receivedAt,
receivedAtMs: receivedAtMs ?? undefined,
sentAt,
},
index: orderInMessage,
attachment: convertAttachmentDBFieldsToAttachmentType(attachment),
};
});
}
function _markCallHistoryMissed(
db: WritableDB,
callIds: ReadonlyArray<string>

View file

@ -330,7 +330,7 @@ function hydrateMessageRootOrRevisionWithAttachments<
return hydratedMessage;
}
function convertAttachmentDBFieldsToAttachmentType(
export function convertAttachmentDBFieldsToAttachmentType(
dbFields: MessageAttachmentDBType
): AttachmentType {
const messageAttachment = shallowDropNull(dbFields);

View file

@ -0,0 +1,43 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { WritableDB } from '../Interface';
export default function updateToSchemaVersion1450(db: WritableDB): void {
db.exec(`
ALTER TABLE message_attachments
ADD COLUMN messageType TEXT;
ALTER TABLE message_attachments
ADD COLUMN receivedAt INTEGER;
ALTER TABLE message_attachments
ADD COLUMN receivedAtMs INTEGER;
ALTER TABLE message_attachments
ADD COLUMN isViewOnce INTEGER;
`);
// Backfill
db.exec(`
UPDATE message_attachments
SET
messageType = messages.type,
receivedAt = messages.received_at,
receivedAtMs = messages.received_at_ms,
isViewOnce = messages.isViewOnce
FROM (
SELECT id, type, received_at, received_at_ms, isViewOnce
FROM messages
) AS messages
WHERE
message_attachments.messageId IS messages.id
`);
// Index
db.exec(`
CREATE INDEX message_attachments_getOlderMedia ON message_attachments
(conversationId, attachmentType, receivedAt DESC, sentAt DESC)
WHERE
editHistoryIndex IS -1 AND
messageType IN ('incoming', 'outgoing') AND
isViewOnce IS NOT 1
`);
}

View file

@ -120,6 +120,7 @@ import updateToSchemaVersion1410 from './1410-remove-wallpaper';
import updateToSchemaVersion1420 from './1420-backup-downloads';
import updateToSchemaVersion1430 from './1430-call-links-epoch-id';
import updateToSchemaVersion1440 from './1440-chat-folders';
import updateToSchemaVersion1450 from './1450-all-media';
import { DataWriter } from '../Server';
@ -1595,6 +1596,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1420, update: updateToSchemaVersion1420 },
{ version: 1430, update: updateToSchemaVersion1430 },
{ version: 1440, update: updateToSchemaVersion1440 },
{ version: 1450, update: updateToSchemaVersion1450 },
];
export class DBVersionFromFutureError extends Error {

View file

@ -33,15 +33,18 @@ const permissiveOptionalBool = z
export const permissiveMessageAttachmentSchema = z.object({
// Fields required to be NOT NULL
messageId: z.string(),
messageType: z.string(),
editHistoryIndex: z.number(),
attachmentType: attachmentDownloadTypeSchema,
orderInMessage: z.number(),
conversationId: z.string(),
sentAt: z.number().catch(0),
receivedAt: z.number().catch(0),
size: z.number().catch(0),
contentType: z.string().catch(APPLICATION_OCTET_STREAM),
// Fields allowing NULL
receivedAtMs: permissiveNumberOrNull,
path: permissiveStringOrNull,
clientUuid: permissiveStringOrNull,
localKey: permissiveStringOrNull,
@ -82,6 +85,7 @@ export const permissiveMessageAttachmentSchema = z.object({
wasTooBig: permissiveOptionalBool,
backfillError: permissiveOptionalBool,
isCorrupted: permissiveOptionalBool,
isViewOnce: permissiveOptionalBool,
copiedFromQuotedAttachment: permissiveOptionalBool,
version: permissiveAttachmentVersion,
pending: permissiveOptionalBool,

View file

@ -60,7 +60,6 @@ import type {
ConversationAttributesType,
DraftEditMessageType,
LastMessageStatus,
MessageAttributesType,
ReadonlyMessageAttributesType,
} from '../../model-types.d';
import type {
@ -215,7 +214,6 @@ import {
} from '../../messages/MessageSendState';
import { markFailed } from '../../test-node/util/messageFailures';
import { cleanupMessages } from '../../util/cleanup';
import { MessageModel } from '../../models/messages';
import type { ConversationModel } from '../../models/conversations';
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent';
@ -1169,7 +1167,6 @@ export const actions = {
loadNewerMessages,
loadNewestMessages,
loadOlderMessages,
loadRecentMediaItems,
markAttachmentAsCorrupted,
markMessageRead,
markOpenConversationRead,
@ -3996,73 +3993,6 @@ function initiateMigrationToGroupV2(conversationId: string): NoopActionType {
};
}
function loadRecentMediaItems(
conversationId: string,
limit: number
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
return async dispatch => {
const messages: Array<MessageAttributesType> =
await DataReader.getOlderMessagesByConversation({
conversationId,
limit,
requireVisualMediaAttachments: true,
storyId: undefined,
includeStoryReplies: false,
});
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageCache.register(new MessageModel(message));
});
let index = 0;
const recentMediaItems = messages
.filter(message => message.attachments !== undefined)
.reduce(
(acc, message) => [
...acc,
...(message.attachments || []).map(
(attachment: AttachmentType): MediaItemType => {
const { thumbnail } = attachment;
const result = {
objectURL: attachment.path
? getLocalAttachmentUrl(attachment)
: '',
thumbnailObjectUrl: thumbnail?.path
? getLocalAttachmentUrl(thumbnail)
: '',
contentType: attachment.contentType,
index,
attachment,
message: {
attachments: message.attachments || [],
conversationId:
window.ConversationController.get(message.sourceServiceId)
?.id || message.conversationId,
id: message.id,
receivedAt: message.received_at,
receivedAtMs: Number(message.received_at_ms),
sentAt: message.sent_at,
},
};
index += 1;
return result;
}
),
],
[] as Array<MediaItemType>
);
dispatch({
type: 'SET_RECENT_MEDIA_ITEMS',
payload: { id: conversationId, recentMediaItems },
});
};
}
export type SaveAttachmentActionCreatorType = ReadonlyDeep<
(attachment: AttachmentType, timestamp?: number, index?: number) => unknown
>;

View file

@ -21,7 +21,6 @@ import { getMessageById } from '../../messages/getMessageById';
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
import {
getUndownloadedAttachmentSignature,
isGIF,
isIncremental,
} from '../../types/Attachment';
import {
@ -32,7 +31,7 @@ import {
getLocalAttachmentUrl,
AttachmentDisposition,
} from '../../util/getLocalAttachmentUrl';
import { isTapToView } from '../selectors/message';
import { isTapToView, getPropsForAttachment } from '../selectors/message';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import {
@ -202,25 +201,26 @@ function showLightboxForViewOnceMedia(
const { path: tempPath } =
await copyAttachmentIntoTempDirectory(absolutePath);
const tempAttachment = {
...firstAttachment,
...getPropsForAttachment(
firstAttachment,
'attachment',
message.attributes
),
path: tempPath,
};
tempAttachment.url = getLocalAttachmentUrl(tempAttachment, {
disposition: AttachmentDisposition.Temporary,
});
await markViewOnceMessageViewed(message);
const { contentType } = tempAttachment;
const media = [
{
attachment: tempAttachment,
objectURL: getLocalAttachmentUrl(tempAttachment, {
disposition: AttachmentDisposition.Temporary,
}),
contentType,
index: 0,
message: {
attachments: message.get('attachments') || [],
id: message.get('id'),
type: message.get('type'),
conversationId: message.get('conversationId'),
receivedAt: message.get('received_at'),
receivedAtMs: Number(message.get('received_at_ms')),
@ -307,7 +307,6 @@ function showLightbox(opts: {
}
const attachments = filterValidAttachments(message.attributes);
const loop = isGIF(attachments);
const authorId =
window.ConversationController.lookupOrCreate({
@ -320,33 +319,25 @@ function showLightbox(opts: {
const media = attachments
.map((item, index) => ({
objectURL: item.path ? getLocalAttachmentUrl(item) : undefined,
incrementalObjectUrl:
isIncremental(item) && item.downloadPath
? getLocalAttachmentUrl(item, {
disposition: AttachmentDisposition.Download,
})
: undefined,
path: item.path,
contentType: item.contentType,
loop,
index,
message: {
attachments: message.get('attachments') || [],
id: messageId,
type: message.get('type'),
conversationId: authorId,
receivedAt,
receivedAtMs: Number(message.get('received_at_ms')),
sentAt,
},
attachment: item,
thumbnailObjectUrl: item.thumbnail?.path
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
attachment: getPropsForAttachment(
item,
'attachment',
message.attributes
),
size: item.size,
totalDownloaded: item.totalDownloaded,
}))
.filter(item => item.objectURL || item.incrementalObjectUrl);
.filter(item => item.attachment.url || item.attachment.incrementalUrl);
if (!media.length) {
log.error(

View file

@ -6,22 +6,17 @@ import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import { createLogger } from '../../logging/log';
import * as Errors from '../../types/errors';
import { DataReader } from '../../sql/Client';
import type { MediaItemDBType } from '../../sql/Interface';
import {
CONVERSATION_UNLOADED,
MESSAGE_CHANGED,
MESSAGE_DELETED,
MESSAGE_EXPIRED,
} from './conversations';
import { VERSION_NEEDED_FOR_DISPLAY } from '../../types/Message2';
import { isDownloading, hasFailed } from '../../types/Attachment';
import { isNotNil } from '../../util/isNotNil';
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
import { getMessageIdForLogging } from '../../util/idForLogging';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { AttachmentType } from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type {
ConversationUnloadedActionType,
@ -29,33 +24,22 @@ import type {
MessageDeletedActionType,
MessageExpiredActionType,
} from './conversations';
import type { MIMEType } from '../../types/MIME';
import type { MediaItemType } from '../../types/MediaItem';
import type { StateType as RootStateType } from '../reducer';
import type { MessageAttributesType } from '../../model-types';
import { MessageModel } from '../../models/messages';
import { isTapToView } from '../selectors/message';
import type { MessageAttributesType, MessageType } from '../../model-types';
import { isTapToView, getPropsForAttachment } from '../selectors/message';
const log = createLogger('mediaGallery');
type MediaItemMessage = ReadonlyDeep<{
attachments: Array<AttachmentType>;
// Note that this reflects the sender, and not the parent conversation
conversationId: string;
type: MessageType;
id: string;
receivedAt: number;
receivedAtMs: number;
sentAt: number;
}>;
type MediaType = ReadonlyDeep<{
path: string;
objectURL: string;
thumbnailObjectUrl?: string;
contentType: MIMEType;
index: number;
attachment: AttachmentType;
message: MediaItemMessage;
}>;
export type MediaGalleryStateType = ReadonlyDeep<{
conversationId: string | undefined;
@ -63,7 +47,7 @@ export type MediaGalleryStateType = ReadonlyDeep<{
haveOldestDocument: boolean;
haveOldestMedia: boolean;
loading: boolean;
media: ReadonlyArray<MediaType>;
media: ReadonlyArray<MediaItemType>;
}>;
const FETCH_CHUNK_COUNT = 50;
@ -78,14 +62,14 @@ type InitialLoadActionType = ReadonlyDeep<{
payload: {
conversationId: string;
documents: ReadonlyArray<MediaItemType>;
media: ReadonlyArray<MediaType>;
media: ReadonlyArray<MediaItemType>;
};
}>;
type LoadMoreMediaActionType = ReadonlyDeep<{
type: typeof LOAD_MORE_MEDIA;
payload: {
conversationId: string;
media: ReadonlyArray<MediaType>;
media: ReadonlyArray<MediaItemType>;
};
}>;
type LoadMoreDocumentsActionType = ReadonlyDeep<{
@ -113,7 +97,9 @@ type MediaGalleryActionType = ReadonlyDeep<
| SetLoadingActionType
>;
function _sortMedia(media: ReadonlyArray<MediaType>): ReadonlyArray<MediaType> {
function _sortMedia(
media: ReadonlyArray<MediaItemType>
): ReadonlyArray<MediaItemType> {
return orderBy(media, [
'message.receivedAt',
'message.sentAt',
@ -130,13 +116,13 @@ function _getMediaItemMessage(
message: ReadonlyDeep<MessageAttributesType>
): MediaItemMessage {
return {
attachments: message.attachments || [],
conversationId:
window.ConversationController.lookupOrCreate({
serviceId: message.sourceServiceId,
e164: message.source,
reason: 'conversation_view.showAllMedia',
})?.id || message.conversationId,
type: message.type,
id: message.id,
receivedAt: message.received_at,
receivedAtMs: Number(message.received_at_ms),
@ -145,99 +131,41 @@ function _getMediaItemMessage(
}
function _cleanVisualAttachments(
rawMedia: ReadonlyDeep<ReadonlyArray<MessageModel>>
): ReadonlyArray<MediaType> {
return rawMedia
.flatMap(message => {
let index = 0;
// Also checked via the DB query
if (isTapToView(message.attributes)) {
return [];
}
return (message.get('attachments') || []).map(
(attachment: AttachmentType): MediaType | undefined => {
if (
!attachment.path ||
!attachment.thumbnail ||
isDownloading(attachment) ||
hasFailed(attachment)
) {
return;
}
const { thumbnail } = attachment;
const result = {
path: attachment.path,
objectURL: getLocalAttachmentUrl(attachment),
thumbnailObjectUrl: thumbnail?.path
? getLocalAttachmentUrl(thumbnail)
: undefined,
contentType: attachment.contentType,
index,
attachment,
message: _getMediaItemMessage(message.attributes),
};
index += 1;
return result;
}
);
})
.filter(isNotNil);
rawMedia: ReadonlyArray<MediaItemDBType>
): ReadonlyArray<MediaItemType> {
return rawMedia.map(({ message, index, attachment }) => {
return {
index,
attachment: getPropsForAttachment(attachment, 'attachment', message),
message,
};
});
}
function _cleanFileAttachments(
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageModel>>
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageAttributesType>>
): ReadonlyArray<MediaItemType> {
return rawDocuments
.map(message => {
if (isTapToView(message.attributes)) {
if (isTapToView(message)) {
return;
}
const attachments = message.get('attachments') || [];
const attachments = message.attachments || [];
const attachment = attachments[0];
if (!attachment) {
return;
}
return {
contentType: attachment.contentType,
index: 0,
attachment,
message: {
..._getMediaItemMessage(message.attributes),
attachments: [attachment],
},
attachment: getPropsForAttachment(attachment, 'attachment', message),
message: _getMediaItemMessage(message),
};
})
.filter(isNotNil);
}
async function _upgradeMessages(
messages: ReadonlyArray<MessageModel>
): Promise<void> {
// We upgrade these messages so they are sure to have thumbnails
await Promise.all(
messages.map(async message => {
try {
await window.MessageCache.upgradeSchema(
message,
VERSION_NEEDED_FOR_DISPLAY
);
} catch (error) {
log.warn(
'_upgradeMessages: Failed to upgrade message ' +
`${getMessageIdForLogging(message.attributes)}: ${Errors.toLogFormat(error)}`
);
return undefined;
}
})
);
}
function initialLoad(
conversationId: string
): ThunkAction<
@ -252,26 +180,17 @@ function initialLoad(
payload: { loading: true },
});
const rawMedia = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
requireVisualMediaAttachments: true,
storyId: undefined,
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const rawDocuments = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
requireFileAttachments: true,
storyId: undefined,
})
).map(item => window.MessageCache.register(new MessageModel(item)));
await _upgradeMessages(rawMedia);
const rawMedia = await DataReader.getOlderMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
});
const rawDocuments = await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
requireFileAttachments: true,
storyId: undefined,
});
const media = _cleanVisualAttachments(rawMedia);
const documents = _cleanFileAttachments(rawDocuments);
@ -319,20 +238,13 @@ function loadMoreMedia(
const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message;
const rawMedia = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
messageId,
receivedAt,
requireVisualMediaAttachments: true,
sentAt,
storyId: undefined,
})
).map(item => window.MessageCache.register(new MessageModel(item)));
await _upgradeMessages(rawMedia);
const rawMedia = await DataReader.getOlderMedia({
conversationId,
limit: FETCH_CHUNK_COUNT,
messageId,
receivedAt,
sentAt,
});
const media = _cleanVisualAttachments(rawMedia);
@ -384,18 +296,16 @@ function loadMoreDocuments(
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
const rawDocuments = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
messageId,
receivedAt,
requireFileAttachments: true,
sentAt,
storyId: undefined,
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const rawDocuments = await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
messageId,
receivedAt,
requireFileAttachments: true,
sentAt,
storyId: undefined,
});
const documents = _cleanFileAttachments(rawDocuments);
@ -519,12 +429,23 @@ export function reducer(
const oldestLoadedMedia = state.media[0];
const oldestLoadedDocument = state.documents[0];
const newMedia = _cleanVisualAttachments([
window.MessageCache.register(new MessageModel(message)),
]);
const newDocuments = _cleanFileAttachments([
window.MessageCache.register(new MessageModel(message)),
]);
const newMedia = _cleanVisualAttachments(
(message.attachments ?? []).map((attachment, index) => {
return {
index,
attachment,
message: {
id: message.id,
type: message.type,
conversationId: message.conversationId,
receivedAt: message.received_at,
receivedAtMs: message.received_at_ms,
sentAt: message.sent_at,
},
};
})
);
const newDocuments = _cleanFileAttachments([message]);
let { documents, haveOldestDocument, haveOldestMedia, media } = state;

View file

@ -66,7 +66,11 @@ import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { isVoiceMessage, defaultBlurHash } from '../../types/Attachment';
import {
isVoiceMessage,
isIncremental,
defaultBlurHash,
} from '../../types/Attachment';
import type { AttachmentDownloadJobTypeType } from '../../types/AttachmentDownload';
import { type DefaultConversationColorType } from '../../types/Colors';
import { ReadStatus } from '../../messages/MessageReadStatus';
@ -79,7 +83,10 @@ import { isMoreRecentThan } from '../../util/timestamp';
import * as iterables from '../../util/iterables';
import { strictAssert } from '../../util/assert';
import { canEditMessage } from '../../util/canEditMessage';
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
import {
getLocalAttachmentUrl,
AttachmentDisposition,
} from '../../util/getLocalAttachmentUrl';
import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager';
import { getAccountSelector } from './accounts';
@ -1853,6 +1860,12 @@ export function getPropsForAttachment(
isVoiceMessage: isVoiceMessage(attachment),
pending,
url: path ? getLocalAttachmentUrl(attachment) : undefined,
incrementalUrl:
isIncremental(attachment) && attachment.downloadPath
? getLocalAttachmentUrl(attachment, {
disposition: AttachmentDisposition.Download,
})
: undefined,
thumbnailFromBackup: thumbnailFromBackup?.path
? {
...thumbnailFromBackup,

View file

@ -4,6 +4,7 @@ import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { MediaGallery } from '../../components/conversation/media-gallery/MediaGallery';
import { getMediaGalleryState } from '../selectors/mediaGallery';
import { getIntl, getTheme } from '../selectors/user';
import { useConversationsActions } from '../ducks/conversations';
import { useLightboxActions } from '../ducks/lightbox';
import { useMediaGalleryActions } from '../ducks/mediaGallery';
@ -19,15 +20,22 @@ export const SmartAllMedia = memo(function SmartAllMedia({
useSelector(getMediaGalleryState);
const { initialLoad, loadMoreMedia, loadMoreDocuments } =
useMediaGalleryActions();
const { saveAttachment } = useConversationsActions();
const {
saveAttachment,
kickOffAttachmentDownload,
cancelAttachmentDownload,
} = useConversationsActions();
const { showLightbox } = useLightboxActions();
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
return (
<MediaGallery
conversationId={conversationId}
haveOldestDocument={haveOldestDocument}
haveOldestMedia={haveOldestMedia}
i18n={window.i18n}
i18n={i18n}
theme={theme}
initialLoad={initialLoad}
loading={loading}
loadMoreMedia={loadMoreMedia}
@ -35,6 +43,8 @@ export const SmartAllMedia = memo(function SmartAllMedia({
media={media}
documents={documents}
showLightbox={showLightbox}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
saveAttachment={saveAttachment}
/>
);

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { sortBy } from 'lodash';
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails';
import {
@ -40,8 +40,9 @@ import { useConversationsActions } from '../ducks/conversations';
import { useCallingActions } from '../ducks/calling';
import { useSearchActions } from '../ducks/search';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useLightboxActions } from '../ducks/lightbox';
import { isSignalConversation } from '../../util/isSignalConversation';
import { drop } from '../../util/drop';
import { DataReader } from '../../sql/Client';
export type SmartConversationDetailsProps = {
conversationId: string;
@ -109,7 +110,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
deleteAvatarFromDisk,
getProfilesForConversation,
leaveGroup,
loadRecentMediaItems,
pushPanelForConversation,
replaceAvatar,
saveAvatarToDisk,
@ -132,7 +132,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
toggleEditNicknameAndNoteModal,
toggleSafetyNumberModal,
} = useGlobalModalActions();
const { showLightbox } = useLightboxActions();
const conversation = conversationSelector(conversationId);
assertDev(
@ -177,6 +176,26 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
const [hasMedia, setHasMedia] = useState(false);
useEffect(() => {
let isCanceled = false;
drop(
(async () => {
const result = await DataReader.hasMedia(conversationId);
if (isCanceled) {
return;
}
setHasMedia(result);
})()
);
return () => {
isCanceled = true;
};
}, [conversationId]);
return (
<ConversationDetails
acceptConversation={acceptConversation}
@ -199,7 +218,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
isGroup={isGroup}
isSignalConversation={isSignalConversation(conversation)}
leaveGroup={leaveGroup}
loadRecentMediaItems={loadRecentMediaItems}
hasMedia={hasMedia}
maxGroupSize={maxGroupSize}
maxRecommendedGroupSize={maxRecommendedGroupSize}
memberships={memberships}
@ -221,7 +240,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
setMuteExpiration={setMuteExpiration}
showContactModal={showContactModal}
showConversation={showConversation}
showLightbox={showLightbox}
startAvatarDownload={() => startAvatarDownload(conversationId)}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}

View file

@ -29,7 +29,6 @@ import { SmartStickerManager } from './StickerManager';
import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType';
import { getIntl } from '../selectors/user';
import {
getIsPanelAnimating,
getPanelInformation,
getWasPanelAnimated,
} from '../selectors/conversations';
@ -111,7 +110,6 @@ export const ConversationPanel = memo(function ConversationPanel({
const i18n = useSelector(getIntl);
const isRTL = i18n.getLocaleDirection() === 'rtl';
const isAnimating = useSelector(getIsPanelAnimating);
const wasAnimated = useSelector(getWasPanelAnimated);
const [lastPanelDoneAnimating, setLastPanelDoneAnimating] =
@ -217,16 +215,22 @@ export const ConversationPanel = memo(function ConversationPanel({
<>
{activePanel && (
<PanelContainer
key={getPanelKey(activePanel)}
conversationId={conversationId}
isActive
panel={activePanel}
/>
)}
{lastPanelDoneAnimating !== prevPanel && (
<div className="ConversationPanel__overlay" ref={overlayRef} />
<div
key="overlay"
className="ConversationPanel__overlay"
ref={overlayRef}
/>
)}
{prevPanel && lastPanelDoneAnimating !== prevPanel && (
<PanelContainer
key={getPanelKey(prevPanel)}
conversationId={conversationId}
panel={prevPanel}
ref={animateRef}
@ -239,11 +243,20 @@ export const ConversationPanel = memo(function ConversationPanel({
if (direction === 'push' && activePanel) {
return (
<>
{isAnimating && prevPanel && (
<PanelContainer conversationId={conversationId} panel={prevPanel} />
{lastPanelDoneAnimating !== prevPanel && prevPanel && (
<PanelContainer
conversationId={conversationId}
panel={prevPanel}
key={getPanelKey(prevPanel)}
/>
)}
<div className="ConversationPanel__overlay" ref={overlayRef} />
<div
key="overlay"
className="ConversationPanel__overlay"
ref={overlayRef}
/>
<PanelContainer
key={getPanelKey(activePanel)}
conversationId={conversationId}
isActive
panel={activePanel}
@ -374,3 +387,25 @@ function PanelElement({
log.warn(toLogFormat(missingCaseError(panel)));
return null;
}
function getPanelKey(panel: PanelRenderType): string {
switch (panel.type) {
case PanelType.AllMedia:
case PanelType.ChatColorEditor:
case PanelType.ConversationDetails:
case PanelType.GroupInvites:
case PanelType.GroupLinkManagement:
case PanelType.GroupPermissions:
case PanelType.GroupV1Members:
case PanelType.NotificationSettings:
case PanelType.StickerManager:
return panel.type;
case PanelType.MessageDetails:
return `${panel.type}:${panel.args.message.id}`;
case PanelType.ContactDetails:
return `${panel.type}:${panel.args.messageId}`;
default:
log.warn(toLogFormat(missingCaseError(panel)));
return 'unknown';
}
}

View file

@ -20,20 +20,19 @@ const testDate = (
const toMediaItem = (id: string, date: Date): MediaItemType => {
return {
objectURL: id,
index: 0,
message: {
type: 'incoming',
conversationId: '1234',
id: 'id',
receivedAt: date.getTime(),
receivedAtMs: date.getTime(),
attachments: [],
sentAt: date.getTime(),
},
attachment: fakeAttachment({
fileName: 'fileName',
contentType: IMAGE_JPEG,
url: 'url',
url: id,
}),
};
};
@ -62,35 +61,35 @@ describe('groupMediaItemsByDate', () => {
assert.strictEqual(actual[0].type, 'today');
assert.strictEqual(actual[0].mediaItems.length, 2, 'today');
assert.strictEqual(actual[0].mediaItems[0].objectURL, 'today-1');
assert.strictEqual(actual[0].mediaItems[1].objectURL, 'today-2');
assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1');
assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2');
assert.strictEqual(actual[1].type, 'yesterday');
assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday');
assert.strictEqual(actual[1].mediaItems[0].objectURL, 'yesterday-1');
assert.strictEqual(actual[1].mediaItems[1].objectURL, 'yesterday-2');
assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1');
assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2');
assert.strictEqual(actual[2].type, 'thisWeek');
assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek');
assert.strictEqual(actual[2].mediaItems[0].objectURL, 'thisWeek-1');
assert.strictEqual(actual[2].mediaItems[1].objectURL, 'thisWeek-2');
assert.strictEqual(actual[2].mediaItems[2].objectURL, 'thisWeek-3');
assert.strictEqual(actual[2].mediaItems[3].objectURL, 'thisWeek-4');
assert.strictEqual(actual[2].mediaItems[0].attachment.url, 'thisWeek-1');
assert.strictEqual(actual[2].mediaItems[1].attachment.url, 'thisWeek-2');
assert.strictEqual(actual[2].mediaItems[2].attachment.url, 'thisWeek-3');
assert.strictEqual(actual[2].mediaItems[3].attachment.url, 'thisWeek-4');
assert.strictEqual(actual[3].type, 'thisMonth');
assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth');
assert.strictEqual(actual[3].mediaItems[0].objectURL, 'thisMonth-1');
assert.strictEqual(actual[3].mediaItems[1].objectURL, 'thisMonth-2');
assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1');
assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2');
assert.strictEqual(actual[4].type, 'yearMonth');
assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024');
assert.strictEqual(actual[4].mediaItems[0].objectURL, 'mar2024-1');
assert.strictEqual(actual[4].mediaItems[1].objectURL, 'mar2024-2');
assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1');
assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2');
assert.strictEqual(actual[5].type, 'yearMonth');
assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011');
assert.strictEqual(actual[5].mediaItems[0].objectURL, 'feb2011-1');
assert.strictEqual(actual[5].mediaItems[1].objectURL, 'feb2011-2');
assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1');
assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2');
assert.strictEqual(actual.length, 6, 'total sections');
});

View file

@ -88,6 +88,7 @@ export type EphemeralAttachmentFields = {
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url?: string;
incrementalUrl?: string;
screenshotData?: Uint8Array;
/** @deprecated Legacy field */
screenshotPath?: string;

View file

@ -1,27 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyMessageAttributesType } from '../model-types.d';
import type { AttachmentType } from './Attachment';
import type { MIMEType } from './MIME';
import type { AttachmentForUIType } from './Attachment';
import type { MessageAttributesType } from '../model-types.d';
export type MediaItemMessageType = Pick<
ReadonlyMessageAttributesType,
'attachments' | 'conversationId' | 'id'
> & {
export type MediaItemMessageType = Readonly<{
id: string;
type: MessageAttributesType['type'];
conversationId: string;
receivedAt: number;
receivedAtMs?: number;
sentAt: number;
};
}>;
export type MediaItemType = {
attachment: AttachmentType;
contentType?: MIMEType;
attachment: AttachmentForUIType;
index: number;
loop?: boolean;
message: MediaItemMessageType;
objectURL?: string;
incrementalObjectUrl?: string;
thumbnailObjectUrl?: string;
size?: number;
};

25
ts/util/randomBlurHash.ts Normal file
View file

@ -0,0 +1,25 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { encode } from 'blurhash';
import { hslToRGB } from './hslToRGB';
export function randomBlurHash(): string {
const data = new Uint8ClampedArray(2 * 2 * 4);
for (let i = 0; i < data.byteLength; i += 4) {
const { r, g, b } = hslToRGB(Math.random() * 360, 1, 0.5);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 0xff;
}
return encode(
data,
2, // width
2, // height
2, // x components
2 // y components
);
}