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": { "icu:ConversationDetailsMediaList--shared-media": {
"messageformat": "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": { "icu:ConversationDetailsMediaList--show-all": {
"messageformat": "See 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": { "icu:ConversationDetailsMembershipList--title": {
"messageformat": "{number, plural, one {# member} other {# members}}", "messageformat": "{number, plural, one {# member} other {# members}}",

View file

@ -2479,6 +2479,7 @@ button.ConversationDetails__action-button {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: 20px; padding: 20px;
} }
@ -2496,161 +2497,13 @@ button.ConversationDetails__action-button {
} }
.module-media-gallery__sections { .module-media-gallery__sections {
min-width: 0;
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
flex-direction: column; 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*/
.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 { &--mention {
&::after { &::after {
@include details-icon('../images/icons/v3/at/at.svg'); @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 { &-panel-row {
$row-root-selector: '#{&}__root'; $row-root-selector: '#{&}__root';
&__root { &__root {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,31 +28,24 @@ export function renderAvatar({
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path; const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
const title = getName(contact) || ''; 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 ( return (
<AttachmentStatusIcon <AttachmentStatusIcon
attachment={avatar?.avatar} attachment={avatar?.avatar}
isAttachmentNotAvailable={isAttachmentNotAvailable}
isIncoming={direction === 'incoming'} 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, isGroup: true,
isSignalConversation: false, isSignalConversation: false,
leaveGroup: action('leaveGroup'), leaveGroup: action('leaveGroup'),
loadRecentMediaItems: action('loadRecentMediaItems'), hasMedia: true,
memberships: times(32, i => ({ memberships: times(32, i => ({
isAdmin: i === 1, isAdmin: i === 1,
member: getDefaultConversation({ member: getDefaultConversation({
@ -86,7 +86,6 @@ const createProps = (
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
showConversation: action('showConversation'), showConversation: action('showConversation'),
showLightbox: action('showLightbox'),
startAvatarDownload: action('startAvatarDownload'), startAvatarDownload: action('startAvatarDownload'),
updateGroupAttributes: async () => { updateGroupAttributes: async () => {
action('updateGroupAttributes')(); action('updateGroupAttributes')();

View file

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

View file

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

View file

@ -6,55 +6,22 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { Props } from './DocumentListItem'; import type { Props } from './DocumentListItem';
import { DocumentListItem } from './DocumentListItem'; import { DocumentListItem } from './DocumentListItem';
import { createPreparedMediaItems, createRandomDocuments } from './utils/mocks';
export default { export default {
title: 'Components/Conversation/MediaGallery/DocumentListItem', 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>; } satisfies Meta<Props>;
export function Single(args: Props): JSX.Element {
return <DocumentListItem {...args} />;
}
export function Multiple(): JSX.Element { export function Multiple(): JSX.Element {
const items = [ const items = createPreparedMediaItems(createRandomDocuments);
{
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,
},
];
return ( return (
<> <>
{items.map(item => ( {items.map(mediaItem => (
<DocumentListItem <DocumentListItem
key={item.fileName} key={mediaItem.attachment.fileName}
mediaItem={mediaItem}
onClick={action('onClick')} onClick={action('onClick')}
{...item}
/> />
))} ))}
</> </>

View file

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

View file

@ -34,10 +34,15 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
loadMoreMedia: action('loadMoreMedia'), loadMoreMedia: action('loadMoreMedia'),
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
showLightbox: action('showLightbox'), showLightbox: action('showLightbox'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
}); });
export function Populated(): JSX.Element { 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 media = createPreparedMediaItems(createRandomMedia);
const props = createProps({ documents, media }); const props = createProps({ documents, media });

View file

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

View file

@ -4,9 +4,15 @@
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext';
import type { MediaItemType } from '../../../types/MediaItem'; import type { MediaItemType } from '../../../types/MediaItem';
import type { AttachmentType } from '../../../types/Attachment'; import { SignalService } from '../../../protobuf';
import { stringToMIMEType } from '../../../types/MIME'; import {
IMAGE_JPEG,
VIDEO_MP4,
APPLICATION_OCTET_STREAM,
type MIMEType,
} from '../../../types/MIME';
import type { Props } from './MediaGridItem'; import type { Props } from './MediaGridItem';
import { MediaGridItem } from './MediaGridItem'; import { MediaGridItem } from './MediaGridItem';
@ -18,21 +24,37 @@ export default {
const createProps = ( const createProps = (
overrideProps: Partial<Props> & { mediaItem: MediaItemType } overrideProps: Partial<Props> & { mediaItem: MediaItemType }
): Props => ({ ): Props => {
i18n, // eslint-disable-next-line react-hooks/rules-of-hooks
mediaItem: overrideProps.mediaItem, const theme = React.useContext(StorybookThemeContext);
onClick: action('onClick'),
}); return {
i18n,
theme,
mediaItem: overrideProps.mediaItem,
onClick: action('onClick'),
};
};
type OverridePropsMediaItemType = Partial<MediaItemType> & {
objectURL?: string;
contentType?: MIMEType;
};
const createMediaItem = ( const createMediaItem = (
overrideProps: Partial<MediaItemType> = {} overrideProps: OverridePropsMediaItemType
): MediaItemType => ({ ): MediaItemType => ({
thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '',
contentType: overrideProps.contentType || stringToMIMEType(''),
index: 0, 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: { message: {
attachments: [], type: 'incoming',
conversationId: '1234', conversationId: '1234',
id: 'id', id: 'id',
receivedAt: Date.now(), receivedAt: Date.now(),
@ -43,8 +65,34 @@ const createMediaItem = (
export function Image(): JSX.Element { export function Image(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg', objectURL: '/fixtures/kitten-1-64-64.jpg',
contentType: stringToMIMEType('image/jpeg'), 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({ const props = createProps({
@ -56,8 +104,42 @@ export function Image(): JSX.Element {
export function Video(): JSX.Element { export function Video(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg', attachment: {
contentType: stringToMIMEType('video/mp4'), 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({ const props = createProps({
@ -69,7 +151,53 @@ export function Video(): JSX.Element {
export function MissingImage(): JSX.Element { export function MissingImage(): JSX.Element {
const mediaItem = createMediaItem({ 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({ const props = createProps({
@ -81,7 +209,7 @@ export function MissingImage(): JSX.Element {
export function MissingVideo(): JSX.Element { export function MissingVideo(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
contentType: stringToMIMEType('video/mp4'), contentType: VIDEO_MP4,
}); });
const props = createProps({ const props = createProps({
@ -93,8 +221,8 @@ export function MissingVideo(): JSX.Element {
export function BrokenImage(): JSX.Element { export function BrokenImage(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
thumbnailObjectUrl: '/missing-fixtures/nope.jpg', objectURL: '/missing-fixtures/nope.jpg',
contentType: stringToMIMEType('image/jpeg'), contentType: IMAGE_JPEG,
}); });
const props = createProps({ const props = createProps({
@ -106,8 +234,8 @@ export function BrokenImage(): JSX.Element {
export function BrokenVideo(): JSX.Element { export function BrokenVideo(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
thumbnailObjectUrl: '/missing-fixtures/nope.mp4', objectURL: '/missing-fixtures/nope.mp4',
contentType: stringToMIMEType('video/mp4'), contentType: VIDEO_MP4,
}); });
const props = createProps({ const props = createProps({
@ -119,7 +247,7 @@ export function BrokenVideo(): JSX.Element {
export function OtherContentType(): JSX.Element { export function OtherContentType(): JSX.Element {
const mediaItem = createMediaItem({ const mediaItem = createMediaItem({
contentType: stringToMIMEType('application/text'), contentType: APPLICATION_OCTET_STREAM,
}); });
const props = createProps({ const props = createProps({

View file

@ -1,105 +1,167 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react'; import React from 'react';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import { import { formatFileSize } from '../../../util/formatFileSize';
isImageTypeSupported, import type { LocalizerType, ThemeType } from '../../../types/Util';
isVideoTypeSupported,
} from '../../../util/GoogleChrome';
import type { LocalizerType } from '../../../types/Util';
import type { MediaItemType } from '../../../types/MediaItem'; 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 = Readonly<{
export type Props = {
mediaItem: ReadonlyDeep<MediaItemType>; mediaItem: ReadonlyDeep<MediaItemType>;
onClick?: () => void; onClick?: (ev: React.MouseEvent) => void;
i18n: LocalizerType; i18n: LocalizerType;
}; theme?: ThemeType;
}>;
function MediaGridItemContent(props: Props) { export function MediaGridItem(props: Props): JSX.Element {
const { mediaItem, i18n } = props; const {
const { attachment, contentType } = mediaItem; mediaItem: { attachment },
i18n,
theme,
onClick,
} = props;
const [imageBroken, setImageBroken] = useState(false); const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
const url = getUrl(attachment);
const handleImageError = useCallback(() => { const { width, height } = attachment;
log.info('Image failed to load; failing over to placeholder');
setImageBroken(true);
}, []);
if (!attachment) { const imageOrBlurHash = (
return null; <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)) { return (
if (imageBroken || !mediaItem.thumbnailObjectUrl) { <button
return ( type="button"
<div className={tw(
className={classNames( 'relative size-30 overflow-hidden rounded-md',
'module-media-grid-item__icon', 'flex items-center justify-center'
'module-media-grid-item__icon-image' )}
)} onClick={onClick}
/> aria-label={label}
); >
} {imageOrBlurHash}
return ( <MetadataOverlay i18n={i18n} attachment={attachment} />
<img <SpinnerOverlay attachment={attachment} />
alt={i18n('icu:lightboxImageAlt')} </button>
className="module-media-grid-item__image" );
src={mediaItem.thumbnailObjectUrl} }
onError={handleImageError}
/> 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)) { const spinnerValue =
if (imageBroken || !mediaItem.thumbnailObjectUrl) { (!attachment.incrementalUrl &&
return ( attachment.size &&
<div attachment.totalDownloaded) ||
className={classNames( undefined;
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
return ( return (
<div className="module-media-grid-item__image-container"> <div
<img className={tw(
alt={i18n('icu:lightboxImageAlt')} 'absolute size-12.5 rounded-full bg-fill-on-media',
className="module-media-grid-item__image" 'flex items-center justify-center'
src={mediaItem.thumbnailObjectUrl} )}
onError={handleImageError} >
{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>
); </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 ( return (
<div <div
className={classNames( className={tw(
'module-media-grid-item__icon', 'absolute end-0 bottom-0 h-11.5 w-full',
'module-media-grid-item__icon-generic' // 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)]'
)} )}
/> >
); <span
} className={tw(
'absolute end-2 bottom-1.5',
export function MediaGridItem(props: Props): JSX.Element { 'type-caption text-[12px] text-label-primary-on-color'
const { onClick } = props; )}
return ( >
<button type="button" className="module-media-grid-item" onClick={onClick}> {text}
<MediaGridItemContent {...props} /> </span>
</button> </div>
); );
} }

View file

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

View file

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

View file

@ -88,6 +88,7 @@ import {
import { import {
hydrateMessage, hydrateMessage,
hydrateMessages, hydrateMessages,
convertAttachmentDBFieldsToAttachmentType,
getAttachmentReferencesForMessages, getAttachmentReferencesForMessages,
ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX, ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
} from './hydration'; } from './hydration';
@ -148,10 +149,12 @@ import type {
GetConversationRangeCenteredOnMessageResultType, GetConversationRangeCenteredOnMessageResultType,
GetKnownMessageAttachmentsResultType, GetKnownMessageAttachmentsResultType,
GetNearbyMessageFromDeletedSetOptionsType, GetNearbyMessageFromDeletedSetOptionsType,
GetOlderMediaOptionsType,
GetRecentStoryRepliesOptionsType, GetRecentStoryRepliesOptionsType,
GetUnreadByConversationAndMarkReadResultType, GetUnreadByConversationAndMarkReadResultType,
IdentityKeyIdType, IdentityKeyIdType,
ItemKeyType, ItemKeyType,
MediaItemDBType,
MessageAttachmentsCursorType, MessageAttachmentsCursorType,
MessageCursorType, MessageCursorType,
MessageMetricsType, MessageMetricsType,
@ -434,6 +437,9 @@ export const DataReader: ServerReadableInterface = {
getCallHistoryGroups, getCallHistoryGroups,
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
hasMedia,
getOlderMedia,
getAllNotificationProfiles, getAllNotificationProfiles,
getNotificationProfileById, getNotificationProfileById,
@ -2481,17 +2487,29 @@ function saveMessageAttachmentsForRootOrEditedVersion(
conversationId: string; conversationId: string;
sent_at: number; sent_at: number;
} & Pick< } & Pick<
MessageAttributesType, MessageType,
| 'attachments' | 'attachments'
| 'bodyAttachment' | 'bodyAttachment'
| 'contact' | 'contact'
| 'preview' | 'preview'
| 'quote' | 'quote'
| 'type'
| 'sticker' | 'sticker'
| 'isViewOnce'
| 'received_at'
| 'received_at_ms'
>, >,
{ editHistoryIndex }: { editHistoryIndex: number | null } { 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; const mainAttachments = message.attachments;
if (mainAttachments) { if (mainAttachments) {
@ -2500,12 +2518,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'attachment', attachmentType: 'attachment',
attachment, attachment,
orderInMessage: i, orderInMessage: i,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
} }
@ -2515,12 +2537,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'long-message', attachmentType: 'long-message',
attachment: bodyAttachment, attachment: bodyAttachment,
orderInMessage: 0, orderInMessage: 0,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
@ -2534,12 +2560,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'preview', attachmentType: 'preview',
attachment, attachment,
orderInMessage: i, orderInMessage: i,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
} }
@ -2554,12 +2584,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'quote', attachmentType: 'quote',
attachment: attachment.thumbnail, attachment: attachment.thumbnail,
orderInMessage: i, orderInMessage: i,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
} }
@ -2576,12 +2610,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'contact', attachmentType: 'contact',
attachment, attachment,
orderInMessage: i, orderInMessage: i,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
} }
@ -2591,12 +2629,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
saveMessageAttachment({ saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType: 'sticker', attachmentType: 'sticker',
attachment: stickerAttachment, attachment: stickerAttachment,
orderInMessage: 0, orderInMessage: 0,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}); });
} }
} }
@ -2620,8 +2662,10 @@ function saveMessageAttachments(
db, db,
{ {
id: message.id, id: message.id,
type: message.type,
conversationId: message.conversationId, conversationId: message.conversationId,
sent_at: editHistory.timestamp, sent_at: editHistory.timestamp,
isViewOnce: message.isViewOnce,
...editHistory, ...editHistory,
}, },
{ editHistoryIndex: idx } { editHistoryIndex: idx }
@ -2632,30 +2676,41 @@ function saveMessageAttachments(
function saveMessageAttachment({ function saveMessageAttachment({
db, db,
messageId, messageId,
messageType,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
attachmentType, attachmentType,
attachment, attachment,
orderInMessage, orderInMessage,
editHistoryIndex, editHistoryIndex,
isViewOnce,
}: { }: {
db: WritableDB; db: WritableDB;
messageId: string; messageId: string;
messageType: string;
conversationId: string; conversationId: string;
sentAt: number; sentAt: number;
receivedAt: number;
receivedAtMs: number | undefined;
attachmentType: AttachmentDownloadJobTypeType; attachmentType: AttachmentDownloadJobTypeType;
attachment: AttachmentType; attachment: AttachmentType;
orderInMessage: number; orderInMessage: number;
editHistoryIndex: number | null; editHistoryIndex: number | null;
isViewOnce: boolean | undefined;
}) { }) {
const unparsedValues: ShallowNullToUndefined<MessageAttachmentDBType> = { const unparsedValues: ShallowNullToUndefined<MessageAttachmentDBType> = {
messageId, messageId,
messageType,
editHistoryIndex: editHistoryIndex:
editHistoryIndex ?? ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX, editHistoryIndex ?? ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
attachmentType, attachmentType,
orderInMessage, orderInMessage,
conversationId, conversationId,
sentAt, sentAt,
receivedAt,
receivedAtMs,
clientUuid: attachment.clientUuid, clientUuid: attachment.clientUuid,
size: attachment.size, size: attachment.size,
contentType: attachment.contentType, contentType: attachment.contentType,
@ -2700,6 +2755,7 @@ function saveMessageAttachment({
wasTooBig: convertOptionalBooleanToInteger(attachment.wasTooBig), wasTooBig: convertOptionalBooleanToInteger(attachment.wasTooBig),
backfillError: convertOptionalBooleanToInteger(attachment.backfillError), backfillError: convertOptionalBooleanToInteger(attachment.backfillError),
isCorrupted: convertOptionalBooleanToInteger(attachment.isCorrupted), isCorrupted: convertOptionalBooleanToInteger(attachment.isCorrupted),
isViewOnce: convertOptionalBooleanToInteger(isViewOnce),
copiedFromQuotedAttachment: copiedFromQuotedAttachment:
'copied' in attachment 'copied' in attachment
? convertOptionalBooleanToInteger(attachment.copied) ? convertOptionalBooleanToInteger(attachment.copied)
@ -5005,6 +5061,94 @@ function hasGroupCallHistoryMessage(
return exists === 1; 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( function _markCallHistoryMissed(
db: WritableDB, db: WritableDB,
callIds: ReadonlyArray<string> callIds: ReadonlyArray<string>

View file

@ -330,7 +330,7 @@ function hydrateMessageRootOrRevisionWithAttachments<
return hydratedMessage; return hydratedMessage;
} }
function convertAttachmentDBFieldsToAttachmentType( export function convertAttachmentDBFieldsToAttachmentType(
dbFields: MessageAttachmentDBType dbFields: MessageAttachmentDBType
): AttachmentType { ): AttachmentType {
const messageAttachment = shallowDropNull(dbFields); 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 updateToSchemaVersion1420 from './1420-backup-downloads';
import updateToSchemaVersion1430 from './1430-call-links-epoch-id'; import updateToSchemaVersion1430 from './1430-call-links-epoch-id';
import updateToSchemaVersion1440 from './1440-chat-folders'; import updateToSchemaVersion1440 from './1440-chat-folders';
import updateToSchemaVersion1450 from './1450-all-media';
import { DataWriter } from '../Server'; import { DataWriter } from '../Server';
@ -1595,6 +1596,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
{ version: 1420, update: updateToSchemaVersion1420 }, { version: 1420, update: updateToSchemaVersion1420 },
{ version: 1430, update: updateToSchemaVersion1430 }, { version: 1430, update: updateToSchemaVersion1430 },
{ version: 1440, update: updateToSchemaVersion1440 }, { version: 1440, update: updateToSchemaVersion1440 },
{ version: 1450, update: updateToSchemaVersion1450 },
]; ];
export class DBVersionFromFutureError extends Error { export class DBVersionFromFutureError extends Error {

View file

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

View file

@ -60,7 +60,6 @@ import type {
ConversationAttributesType, ConversationAttributesType,
DraftEditMessageType, DraftEditMessageType,
LastMessageStatus, LastMessageStatus,
MessageAttributesType,
ReadonlyMessageAttributesType, ReadonlyMessageAttributesType,
} from '../../model-types.d'; } from '../../model-types.d';
import type { import type {
@ -215,7 +214,6 @@ import {
} from '../../messages/MessageSendState'; } from '../../messages/MessageSendState';
import { markFailed } from '../../test-node/util/messageFailures'; import { markFailed } from '../../test-node/util/messageFailures';
import { cleanupMessages } from '../../util/cleanup'; import { cleanupMessages } from '../../util/cleanup';
import { MessageModel } from '../../models/messages';
import type { ConversationModel } from '../../models/conversations'; import type { ConversationModel } from '../../models/conversations';
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent'; import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent';
@ -1169,7 +1167,6 @@ export const actions = {
loadNewerMessages, loadNewerMessages,
loadNewestMessages, loadNewestMessages,
loadOlderMessages, loadOlderMessages,
loadRecentMediaItems,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markMessageRead, markMessageRead,
markOpenConversationRead, 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< export type SaveAttachmentActionCreatorType = ReadonlyDeep<
(attachment: AttachmentType, timestamp?: number, index?: number) => unknown (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 type { ReadonlyMessageAttributesType } from '../../model-types.d';
import { import {
getUndownloadedAttachmentSignature, getUndownloadedAttachmentSignature,
isGIF,
isIncremental, isIncremental,
} from '../../types/Attachment'; } from '../../types/Attachment';
import { import {
@ -32,7 +31,7 @@ import {
getLocalAttachmentUrl, getLocalAttachmentUrl,
AttachmentDisposition, AttachmentDisposition,
} from '../../util/getLocalAttachmentUrl'; } from '../../util/getLocalAttachmentUrl';
import { isTapToView } from '../selectors/message'; import { isTapToView, getPropsForAttachment } from '../selectors/message';
import { SHOW_TOAST } from './toast'; import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import { import {
@ -202,25 +201,26 @@ function showLightboxForViewOnceMedia(
const { path: tempPath } = const { path: tempPath } =
await copyAttachmentIntoTempDirectory(absolutePath); await copyAttachmentIntoTempDirectory(absolutePath);
const tempAttachment = { const tempAttachment = {
...firstAttachment, ...getPropsForAttachment(
firstAttachment,
'attachment',
message.attributes
),
path: tempPath, path: tempPath,
}; };
tempAttachment.url = getLocalAttachmentUrl(tempAttachment, {
disposition: AttachmentDisposition.Temporary,
});
await markViewOnceMessageViewed(message); await markViewOnceMessageViewed(message);
const { contentType } = tempAttachment;
const media = [ const media = [
{ {
attachment: tempAttachment, attachment: tempAttachment,
objectURL: getLocalAttachmentUrl(tempAttachment, {
disposition: AttachmentDisposition.Temporary,
}),
contentType,
index: 0, index: 0,
message: { message: {
attachments: message.get('attachments') || [],
id: message.get('id'), id: message.get('id'),
type: message.get('type'),
conversationId: message.get('conversationId'), conversationId: message.get('conversationId'),
receivedAt: message.get('received_at'), receivedAt: message.get('received_at'),
receivedAtMs: Number(message.get('received_at_ms')), receivedAtMs: Number(message.get('received_at_ms')),
@ -307,7 +307,6 @@ function showLightbox(opts: {
} }
const attachments = filterValidAttachments(message.attributes); const attachments = filterValidAttachments(message.attributes);
const loop = isGIF(attachments);
const authorId = const authorId =
window.ConversationController.lookupOrCreate({ window.ConversationController.lookupOrCreate({
@ -320,33 +319,25 @@ function showLightbox(opts: {
const media = attachments const media = attachments
.map((item, index) => ({ .map((item, index) => ({
objectURL: item.path ? getLocalAttachmentUrl(item) : undefined,
incrementalObjectUrl:
isIncremental(item) && item.downloadPath
? getLocalAttachmentUrl(item, {
disposition: AttachmentDisposition.Download,
})
: undefined,
path: item.path, path: item.path,
contentType: item.contentType,
loop,
index, index,
message: { message: {
attachments: message.get('attachments') || [],
id: messageId, id: messageId,
type: message.get('type'),
conversationId: authorId, conversationId: authorId,
receivedAt, receivedAt,
receivedAtMs: Number(message.get('received_at_ms')), receivedAtMs: Number(message.get('received_at_ms')),
sentAt, sentAt,
}, },
attachment: item, attachment: getPropsForAttachment(
thumbnailObjectUrl: item.thumbnail?.path item,
? getLocalAttachmentUrl(item.thumbnail) 'attachment',
: undefined, message.attributes
),
size: item.size, size: item.size,
totalDownloaded: item.totalDownloaded, totalDownloaded: item.totalDownloaded,
})) }))
.filter(item => item.objectURL || item.incrementalObjectUrl); .filter(item => item.attachment.url || item.attachment.incrementalUrl);
if (!media.length) { if (!media.length) {
log.error( log.error(

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,6 @@ import { SmartStickerManager } from './StickerManager';
import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType'; import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { import {
getIsPanelAnimating,
getPanelInformation, getPanelInformation,
getWasPanelAnimated, getWasPanelAnimated,
} from '../selectors/conversations'; } from '../selectors/conversations';
@ -111,7 +110,6 @@ export const ConversationPanel = memo(function ConversationPanel({
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const isRTL = i18n.getLocaleDirection() === 'rtl'; const isRTL = i18n.getLocaleDirection() === 'rtl';
const isAnimating = useSelector(getIsPanelAnimating);
const wasAnimated = useSelector(getWasPanelAnimated); const wasAnimated = useSelector(getWasPanelAnimated);
const [lastPanelDoneAnimating, setLastPanelDoneAnimating] = const [lastPanelDoneAnimating, setLastPanelDoneAnimating] =
@ -217,16 +215,22 @@ export const ConversationPanel = memo(function ConversationPanel({
<> <>
{activePanel && ( {activePanel && (
<PanelContainer <PanelContainer
key={getPanelKey(activePanel)}
conversationId={conversationId} conversationId={conversationId}
isActive isActive
panel={activePanel} panel={activePanel}
/> />
)} )}
{lastPanelDoneAnimating !== prevPanel && ( {lastPanelDoneAnimating !== prevPanel && (
<div className="ConversationPanel__overlay" ref={overlayRef} /> <div
key="overlay"
className="ConversationPanel__overlay"
ref={overlayRef}
/>
)} )}
{prevPanel && lastPanelDoneAnimating !== prevPanel && ( {prevPanel && lastPanelDoneAnimating !== prevPanel && (
<PanelContainer <PanelContainer
key={getPanelKey(prevPanel)}
conversationId={conversationId} conversationId={conversationId}
panel={prevPanel} panel={prevPanel}
ref={animateRef} ref={animateRef}
@ -239,11 +243,20 @@ export const ConversationPanel = memo(function ConversationPanel({
if (direction === 'push' && activePanel) { if (direction === 'push' && activePanel) {
return ( return (
<> <>
{isAnimating && prevPanel && ( {lastPanelDoneAnimating !== prevPanel && prevPanel && (
<PanelContainer conversationId={conversationId} panel={prevPanel} /> <PanelContainer
conversationId={conversationId}
panel={prevPanel}
key={getPanelKey(prevPanel)}
/>
)} )}
<div className="ConversationPanel__overlay" ref={overlayRef} /> <div
key="overlay"
className="ConversationPanel__overlay"
ref={overlayRef}
/>
<PanelContainer <PanelContainer
key={getPanelKey(activePanel)}
conversationId={conversationId} conversationId={conversationId}
isActive isActive
panel={activePanel} panel={activePanel}
@ -374,3 +387,25 @@ function PanelElement({
log.warn(toLogFormat(missingCaseError(panel))); log.warn(toLogFormat(missingCaseError(panel)));
return null; 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 => { const toMediaItem = (id: string, date: Date): MediaItemType => {
return { return {
objectURL: id,
index: 0, index: 0,
message: { message: {
type: 'incoming',
conversationId: '1234', conversationId: '1234',
id: 'id', id: 'id',
receivedAt: date.getTime(), receivedAt: date.getTime(),
receivedAtMs: date.getTime(), receivedAtMs: date.getTime(),
attachments: [],
sentAt: date.getTime(), sentAt: date.getTime(),
}, },
attachment: fakeAttachment({ attachment: fakeAttachment({
fileName: 'fileName', fileName: 'fileName',
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
url: 'url', url: id,
}), }),
}; };
}; };
@ -62,35 +61,35 @@ describe('groupMediaItemsByDate', () => {
assert.strictEqual(actual[0].type, 'today'); assert.strictEqual(actual[0].type, 'today');
assert.strictEqual(actual[0].mediaItems.length, 2, 'today'); assert.strictEqual(actual[0].mediaItems.length, 2, 'today');
assert.strictEqual(actual[0].mediaItems[0].objectURL, 'today-1'); assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1');
assert.strictEqual(actual[0].mediaItems[1].objectURL, 'today-2'); assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2');
assert.strictEqual(actual[1].type, 'yesterday'); assert.strictEqual(actual[1].type, 'yesterday');
assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday'); assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday');
assert.strictEqual(actual[1].mediaItems[0].objectURL, 'yesterday-1'); assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1');
assert.strictEqual(actual[1].mediaItems[1].objectURL, 'yesterday-2'); assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2');
assert.strictEqual(actual[2].type, 'thisWeek'); assert.strictEqual(actual[2].type, 'thisWeek');
assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek'); assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek');
assert.strictEqual(actual[2].mediaItems[0].objectURL, 'thisWeek-1'); assert.strictEqual(actual[2].mediaItems[0].attachment.url, 'thisWeek-1');
assert.strictEqual(actual[2].mediaItems[1].objectURL, 'thisWeek-2'); assert.strictEqual(actual[2].mediaItems[1].attachment.url, 'thisWeek-2');
assert.strictEqual(actual[2].mediaItems[2].objectURL, 'thisWeek-3'); assert.strictEqual(actual[2].mediaItems[2].attachment.url, 'thisWeek-3');
assert.strictEqual(actual[2].mediaItems[3].objectURL, 'thisWeek-4'); assert.strictEqual(actual[2].mediaItems[3].attachment.url, 'thisWeek-4');
assert.strictEqual(actual[3].type, 'thisMonth'); assert.strictEqual(actual[3].type, 'thisMonth');
assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth'); assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth');
assert.strictEqual(actual[3].mediaItems[0].objectURL, 'thisMonth-1'); assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1');
assert.strictEqual(actual[3].mediaItems[1].objectURL, 'thisMonth-2'); assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2');
assert.strictEqual(actual[4].type, 'yearMonth'); assert.strictEqual(actual[4].type, 'yearMonth');
assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024'); assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024');
assert.strictEqual(actual[4].mediaItems[0].objectURL, 'mar2024-1'); assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1');
assert.strictEqual(actual[4].mediaItems[1].objectURL, 'mar2024-2'); assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2');
assert.strictEqual(actual[5].type, 'yearMonth'); assert.strictEqual(actual[5].type, 'yearMonth');
assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011'); assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011');
assert.strictEqual(actual[5].mediaItems[0].objectURL, 'feb2011-1'); assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1');
assert.strictEqual(actual[5].mediaItems[1].objectURL, 'feb2011-2'); assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2');
assert.strictEqual(actual.length, 6, 'total sections'); assert.strictEqual(actual.length, 6, 'total sections');
}); });

View file

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

View file

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