Update styles for MediaGallery
This commit is contained in:
parent
11e612f57b
commit
53d1650844
41 changed files with 999 additions and 920 deletions
|
@ -5918,11 +5918,15 @@
|
|||
},
|
||||
"icu:ConversationDetailsMediaList--shared-media": {
|
||||
"messageformat": "Shared media",
|
||||
"description": "Title for the media thumbnails in the conversation details screen"
|
||||
"description": "(Deleted 2025/07/01) Title for the media thumbnails in the conversation details screen"
|
||||
},
|
||||
"icu:ConversationDetailsMediaList--show-all": {
|
||||
"messageformat": "See all",
|
||||
"description": "This is a button on the conversation details to show all media"
|
||||
"description": "(Deleted 2025/07/01) This is a button on the conversation details to show all media"
|
||||
},
|
||||
"icu:ConversationDetailsMediaList--title": {
|
||||
"messageformat": "Media, links, and files",
|
||||
"description": "Title for the show all media button in the conversation details screen"
|
||||
},
|
||||
"icu:ConversationDetailsMembershipList--title": {
|
||||
"messageformat": "{number, plural, one {# member} other {# members}}",
|
||||
|
|
|
@ -2479,6 +2479,7 @@ button.ConversationDetails__action-button {
|
|||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
@ -2496,161 +2497,13 @@ button.ConversationDetails__action-button {
|
|||
}
|
||||
|
||||
.module-media-gallery__sections {
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Module: Attachment Section
|
||||
|
||||
.module-attachment-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.module-attachment-section__header {
|
||||
@include mixins.font-body-1-bold;
|
||||
}
|
||||
|
||||
.module-attachment-section__items {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// Module: Document List Item
|
||||
|
||||
.module-document-list-item {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.module-document-list-item--with-separator {
|
||||
@include mixins.light-theme {
|
||||
border-bottom: 1px solid variables.$color-gray-02;
|
||||
}
|
||||
@include mixins.dark-theme {
|
||||
border-bottom: 1px solid variables.$color-gray-75;
|
||||
}
|
||||
}
|
||||
|
||||
.module-document-list-item__content {
|
||||
@include mixins.button-reset;
|
||||
& {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@include mixins.keyboard-mode {
|
||||
&:focus {
|
||||
box-shadow: 0px 0px 0px 2px variables.$color-ultramarine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.module-document-list-item__icon {
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@include mixins.color-svg(
|
||||
'../images/generic-file.svg',
|
||||
variables.$color-gray-45
|
||||
);
|
||||
}
|
||||
|
||||
.module-document-list-item__metadata {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.module-document-list-item__file-size {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
@include mixins.font-body-2;
|
||||
}
|
||||
|
||||
.module-document-list-item__date {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Module: Media Grid Item
|
||||
|
||||
.module-media-grid-item {
|
||||
@include mixins.button-reset;
|
||||
& {
|
||||
height: 94px;
|
||||
width: 94px;
|
||||
background-color: variables.$color-gray-05;
|
||||
margin-inline-end: 4px;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@include mixins.keyboard-mode {
|
||||
&:focus {
|
||||
box-shadow: 0px 0px 0px 2px variables.$color-ultramarine;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.module-media-grid-item__image {
|
||||
height: 94px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.module-media-grid-item__icon {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
bottom: 15px;
|
||||
inset-inline: 15px;
|
||||
}
|
||||
|
||||
.module-media-grid-item__icon-image {
|
||||
@include mixins.color-svg('../images/image.svg', variables.$color-gray-45);
|
||||
}
|
||||
|
||||
.module-media-grid-item__image-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.module-media-grid-item__circle-overlay {
|
||||
@include mixins.position-absolute-center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background-color: variables.$color-white;
|
||||
border-radius: 21px;
|
||||
}
|
||||
|
||||
.module-media-grid-item__play-overlay {
|
||||
@include mixins.position-absolute-center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
@include mixins.color-svg(
|
||||
'../images/icons/v3/play/play-fill.svg',
|
||||
variables.$color-ultramarine
|
||||
);
|
||||
}
|
||||
|
||||
.module-media-grid-item__icon-video {
|
||||
@include mixins.color-svg('../images/movie.svg', variables.$color-gray-45);
|
||||
}
|
||||
|
||||
.module-media-grid-item__icon-generic {
|
||||
@include mixins.color-svg('../images/file.svg', variables.$color-gray-45);
|
||||
}
|
||||
|
||||
/* Module: Empty State*/
|
||||
|
||||
.module-empty-state {
|
||||
|
|
|
@ -131,6 +131,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--media {
|
||||
&::after {
|
||||
@include details-icon('../images/icons/v3/album/album-tilt.svg');
|
||||
}
|
||||
}
|
||||
|
||||
&--mention {
|
||||
&::after {
|
||||
@include details-icon('../images/icons/v3/at/at.svg');
|
||||
|
@ -282,46 +288,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-media-list {
|
||||
&__root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-block: 0;
|
||||
padding-inline: 20px;
|
||||
padding-bottom: 24px;
|
||||
|
||||
.module-media-grid-item {
|
||||
border-radius: 4px;
|
||||
height: auto;
|
||||
margin-block: 0;
|
||||
margin-inline: 4px;
|
||||
max-height: 94px;
|
||||
overflow: hidden;
|
||||
width: calc(100% / 6);
|
||||
|
||||
.module-media-grid-item__icon {
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.module-media-grid-item__image-container,
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__show-all {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: light-dark(variables.$color-gray-95, variables.$color-gray-05);
|
||||
}
|
||||
}
|
||||
|
||||
&-panel-row {
|
||||
$row-root-selector: '#{&}__root';
|
||||
&__root {
|
||||
|
|
|
@ -246,7 +246,7 @@ export namespace AxoSymbol {
|
|||
*/
|
||||
|
||||
export type IconProps = Readonly<{
|
||||
size: 14 | 16 | 20;
|
||||
size: 14 | 16 | 20 | 24;
|
||||
symbol: AxoSymbolName;
|
||||
label: string | null;
|
||||
}>;
|
||||
|
|
|
@ -67,7 +67,6 @@ export function ImageOrBlurhash({
|
|||
backgroundPosition: 'center',
|
||||
}}
|
||||
loading={blurHashUrl != null ? 'lazy' : 'eager'}
|
||||
decoding={blurHashUrl != null ? 'async' : 'auto'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
VIDEO_MP4,
|
||||
VIDEO_QUICKTIME,
|
||||
stringToMIMEType,
|
||||
type MIMEType,
|
||||
} from '../types/MIME';
|
||||
|
||||
import { fakeAttachment } from '../test-helpers/fakeAttachment';
|
||||
|
@ -26,7 +27,11 @@ export default {
|
|||
args: {},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
type OverridePropsMediaItemType = Partial<MediaItemType> & { caption?: string };
|
||||
type OverridePropsMediaItemType = Partial<MediaItemType> & {
|
||||
caption?: string;
|
||||
objectURL?: string;
|
||||
contentType?: MIMEType;
|
||||
};
|
||||
|
||||
function createMediaItem(
|
||||
overrideProps: OverridePropsMediaItemType
|
||||
|
@ -34,21 +39,19 @@ function createMediaItem(
|
|||
return {
|
||||
attachment: fakeAttachment({
|
||||
caption: overrideProps.caption || '',
|
||||
contentType: IMAGE_JPEG,
|
||||
contentType: overrideProps.contentType ?? IMAGE_JPEG,
|
||||
fileName: overrideProps.objectURL,
|
||||
url: overrideProps.objectURL,
|
||||
}),
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
type: 'incoming',
|
||||
id: 'image-msg',
|
||||
receivedAt: 0,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
objectURL: '',
|
||||
...overrideProps,
|
||||
};
|
||||
}
|
||||
|
@ -88,17 +91,15 @@ export function Multimedia(): JSX.Element {
|
|||
caption:
|
||||
'Still from The Lighthouse, starring Robert Pattinson and Willem Defoe.',
|
||||
}),
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
type: 'incoming',
|
||||
id: 'image-msg',
|
||||
receivedAt: 1,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
objectURL: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
},
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
|
@ -106,28 +107,24 @@ export function Multimedia(): JSX.Element {
|
|||
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
||||
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
}),
|
||||
contentType: VIDEO_MP4,
|
||||
index: 1,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
type: 'incoming',
|
||||
id: 'video-msg',
|
||||
receivedAt: 2,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||
},
|
||||
createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 2,
|
||||
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
objectURL: '/fixtures/kitten-1-64-64.jpg',
|
||||
}),
|
||||
createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 3,
|
||||
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
objectURL: '/fixtures/kitten-2-64-64.jpg',
|
||||
}),
|
||||
],
|
||||
|
@ -145,17 +142,15 @@ export function MissingMedia(): JSX.Element {
|
|||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
}),
|
||||
contentType: IMAGE_JPEG,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: [],
|
||||
conversationId: '1234',
|
||||
type: 'incoming',
|
||||
id: 'image-msg',
|
||||
receivedAt: 3,
|
||||
receivedAtMs: Date.now(),
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
objectURL: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -140,13 +140,10 @@ export function Lightbox({
|
|||
>();
|
||||
|
||||
const currentItem = media[selectedIndex];
|
||||
const {
|
||||
attachment,
|
||||
contentType,
|
||||
loop = false,
|
||||
objectURL,
|
||||
incrementalObjectUrl,
|
||||
} = currentItem || {};
|
||||
const attachment = currentItem?.attachment;
|
||||
const url = attachment?.url;
|
||||
const incrementalUrl = attachment?.incrementalUrl;
|
||||
const contentType = attachment?.contentType;
|
||||
|
||||
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
|
||||
const isDownloading =
|
||||
|
@ -599,7 +596,7 @@ export function Lightbox({
|
|||
!isVideoTypeSupported && isVideo(contentType);
|
||||
|
||||
if (isImageTypeSupported) {
|
||||
if (objectURL) {
|
||||
if (url) {
|
||||
content = (
|
||||
<div className="Lightbox__zoomable-container">
|
||||
<button
|
||||
|
@ -621,7 +618,7 @@ export function Lightbox({
|
|||
ev.preventDefault();
|
||||
}
|
||||
}}
|
||||
src={objectURL}
|
||||
src={url}
|
||||
ref={imageRef}
|
||||
/>
|
||||
</button>
|
||||
|
@ -642,19 +639,19 @@ export function Lightbox({
|
|||
);
|
||||
}
|
||||
} else if (isVideoTypeSupported) {
|
||||
const shouldLoop = loop || isAttachmentGIF || isViewOnce;
|
||||
const shouldLoop = isAttachmentGIF || isViewOnce;
|
||||
|
||||
content = (
|
||||
<video
|
||||
className="Lightbox__object Lightbox__object--video"
|
||||
controls={!shouldLoop}
|
||||
key={objectURL || incrementalObjectUrl}
|
||||
key={url || incrementalUrl}
|
||||
loop={shouldLoop}
|
||||
ref={setVideoElement}
|
||||
onMouseMove={onUserInteractionOnVideo}
|
||||
onMouseLeave={onMouseLeaveVideo}
|
||||
>
|
||||
<source src={objectURL || incrementalObjectUrl} />
|
||||
<source src={url || incrementalUrl} />
|
||||
</video>
|
||||
);
|
||||
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
|
||||
|
@ -834,7 +831,7 @@ export function Lightbox({
|
|||
'Lightbox__thumbnail--selected':
|
||||
index === selectedIndex,
|
||||
})}
|
||||
key={item.thumbnailObjectUrl}
|
||||
key={item.attachment.thumbnail?.url}
|
||||
type="button"
|
||||
onClick={(
|
||||
event: React.MouseEvent<
|
||||
|
@ -848,10 +845,10 @@ export function Lightbox({
|
|||
onSelectAttachment(index);
|
||||
}}
|
||||
>
|
||||
{item.thumbnailObjectUrl ? (
|
||||
{item.attachment.thumbnail?.url ? (
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
src={item.thumbnailObjectUrl}
|
||||
src={item.attachment.thumbnail.url}
|
||||
/>
|
||||
) : (
|
||||
<div className="Lightbox__thumbnail--unavailable" />
|
||||
|
|
|
@ -15,16 +15,13 @@ export default {
|
|||
args: {
|
||||
attachment: fakeAttachment(),
|
||||
isIncoming: false,
|
||||
renderAttachmentDownloaded: () => {
|
||||
return <div>🔥🔥</div>;
|
||||
},
|
||||
},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function Default(args: PropsType): JSX.Element {
|
||||
return (
|
||||
<div style={{ backgroundColor: 'gray' }}>
|
||||
<AttachmentStatusIcon {...args} />
|
||||
<AttachmentStatusIcon {...args}>🔥🔥</AttachmentStatusIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -32,7 +29,9 @@ export function Default(args: PropsType): JSX.Element {
|
|||
export function NoAttachment(args: PropsType): JSX.Element {
|
||||
return (
|
||||
<div style={{ backgroundColor: 'gray' }}>
|
||||
<AttachmentStatusIcon {...args} attachment={undefined} />
|
||||
<AttachmentStatusIcon {...args} attachment={undefined}>
|
||||
🔥🔥
|
||||
</AttachmentStatusIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -43,7 +42,9 @@ export function NeedsDownload(args: PropsType): JSX.Element {
|
|||
<AttachmentStatusIcon
|
||||
{...args}
|
||||
attachment={fakeAttachment({ path: undefined })}
|
||||
/>
|
||||
>
|
||||
🔥🔥
|
||||
</AttachmentStatusIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -59,7 +60,9 @@ export function Downloading(args: PropsType): JSX.Element {
|
|||
size: 1000000,
|
||||
totalDownloaded: 750000,
|
||||
})}
|
||||
/>
|
||||
>
|
||||
🔥🔥
|
||||
</AttachmentStatusIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -106,7 +109,9 @@ export function Interactive(args: PropsType): JSX.Element {
|
|||
<button type="button" onClick={cancelAttachmentDownload}>
|
||||
stop download
|
||||
</button>
|
||||
<AttachmentStatusIcon {...args} attachment={attachment} />
|
||||
<AttachmentStatusIcon {...args} attachment={attachment}>
|
||||
🔥🔥
|
||||
</AttachmentStatusIcon>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, useState, type ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { SpinnerV2 } from '../SpinnerV2';
|
||||
|
@ -13,9 +13,9 @@ const TRANSITION_DELAY = 200;
|
|||
|
||||
export type PropsType = {
|
||||
attachment: AttachmentForUIType | undefined;
|
||||
isAttachmentNotAvailable: boolean;
|
||||
isExpired?: boolean;
|
||||
isIncoming: boolean;
|
||||
renderAttachmentDownloaded: () => JSX.Element;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
enum IconState {
|
||||
|
@ -26,12 +26,18 @@ enum IconState {
|
|||
|
||||
export function AttachmentStatusIcon({
|
||||
attachment,
|
||||
isAttachmentNotAvailable,
|
||||
isExpired,
|
||||
isIncoming,
|
||||
renderAttachmentDownloaded,
|
||||
children,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [isWaiting, setIsWaiting] = useState<boolean>(false);
|
||||
|
||||
const isAttachmentNotAvailable =
|
||||
isExpired ||
|
||||
(attachment != null &&
|
||||
attachment.isPermanentlyUndownloadable &&
|
||||
!attachment.wasTooBig);
|
||||
|
||||
let state: IconState = IconState.Downloaded;
|
||||
if (attachment && isAttachmentNotAvailable) {
|
||||
state = IconState.Downloaded;
|
||||
|
@ -159,9 +165,5 @@ export function AttachmentStatusIcon({
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="AttachmentStatusIcon__container">
|
||||
{renderAttachmentDownloaded()}
|
||||
</div>
|
||||
);
|
||||
return <div className="AttachmentStatusIcon__container">{children}</div>;
|
||||
}
|
||||
|
|
|
@ -1367,10 +1367,6 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
const { fileName, size, contentType } = firstAttachment;
|
||||
const isIncoming = direction === 'incoming';
|
||||
|
||||
const renderAttachmentDownloaded = () => {
|
||||
return <FileThumbnail contentType={contentType} fileName={fileName} />;
|
||||
};
|
||||
|
||||
const willShowMetadata =
|
||||
expirationLength || expirationTimestamp || !shouldHideMetadata;
|
||||
|
||||
|
@ -1415,10 +1411,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
<AttachmentStatusIcon
|
||||
key={id}
|
||||
attachment={firstAttachment}
|
||||
isAttachmentNotAvailable={isAttachmentNotAvailable}
|
||||
isIncoming={isIncoming}
|
||||
renderAttachmentDownloaded={renderAttachmentDownloaded}
|
||||
/>
|
||||
>
|
||||
<FileThumbnail contentType={contentType} fileName={fileName} />
|
||||
</AttachmentStatusIcon>
|
||||
<div className="module-message__simple-attachment__text">
|
||||
<div
|
||||
className={classNames(
|
||||
|
@ -2852,10 +2848,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
<AttachmentStatusIcon
|
||||
key={id}
|
||||
attachment={firstAttachment}
|
||||
isAttachmentNotAvailable={isExpired}
|
||||
isExpired={isExpired}
|
||||
isIncoming={isIncoming}
|
||||
renderAttachmentDownloaded={() => this.renderTapToViewIcon()}
|
||||
/>
|
||||
>
|
||||
{this.renderTapToViewIcon()}
|
||||
</AttachmentStatusIcon>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -28,31 +28,24 @@ export function renderAvatar({
|
|||
|
||||
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
|
||||
const title = getName(contact) || '';
|
||||
const isAttachmentNotAvailable = Boolean(
|
||||
avatar?.avatar?.isPermanentlyUndownloadable
|
||||
);
|
||||
|
||||
const renderAttachmentDownloaded = () => (
|
||||
<Avatar
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
blur={AvatarBlur.NoBlur}
|
||||
color={AvatarColors[0]}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
title={title}
|
||||
sharedGroupNames={[]}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachmentStatusIcon
|
||||
attachment={avatar?.avatar}
|
||||
isAttachmentNotAvailable={isAttachmentNotAvailable}
|
||||
isIncoming={direction === 'incoming'}
|
||||
renderAttachmentDownloaded={renderAttachmentDownloaded}
|
||||
/>
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
blur={AvatarBlur.NoBlur}
|
||||
color={AvatarColors[0]}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
title={title}
|
||||
sharedGroupNames={[]}
|
||||
size={size}
|
||||
/>
|
||||
</AttachmentStatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -65,7 +65,7 @@ const createProps = (
|
|||
isGroup: true,
|
||||
isSignalConversation: false,
|
||||
leaveGroup: action('leaveGroup'),
|
||||
loadRecentMediaItems: action('loadRecentMediaItems'),
|
||||
hasMedia: true,
|
||||
memberships: times(32, i => ({
|
||||
isAdmin: i === 1,
|
||||
member: getDefaultConversation({
|
||||
|
@ -86,7 +86,6 @@ const createProps = (
|
|||
showContactModal: action('showContactModal'),
|
||||
pushPanelForConversation: action('pushPanelForConversation'),
|
||||
showConversation: action('showConversation'),
|
||||
showLightbox: action('showLightbox'),
|
||||
startAvatarDownload: action('startAvatarDownload'),
|
||||
updateGroupAttributes: async () => {
|
||||
action('updateGroupAttributes')();
|
||||
|
|
|
@ -29,8 +29,6 @@ import { AddGroupMembersModal } from './AddGroupMembersModal';
|
|||
import { ConversationDetailsActions } from './ConversationDetailsActions';
|
||||
import { ConversationDetailsHeader } from './ConversationDetailsHeader';
|
||||
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
|
||||
import type { Props as ConversationDetailsMediaListPropsType } from './ConversationDetailsMediaList';
|
||||
import { ConversationDetailsMediaList } from './ConversationDetailsMediaList';
|
||||
import type { GroupV2Membership } from './ConversationDetailsMembershipList';
|
||||
import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList';
|
||||
import type {
|
||||
|
@ -82,6 +80,7 @@ export type StateProps = {
|
|||
canAddNewMembers: boolean;
|
||||
conversation?: ConversationType;
|
||||
hasGroupLink: boolean;
|
||||
hasMedia: boolean;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
hasActiveCall: boolean;
|
||||
i18n: LocalizerType;
|
||||
|
@ -122,7 +121,6 @@ type ActionProps = {
|
|||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
getProfilesForConversation: (id: string) => unknown;
|
||||
leaveGroup: (conversationId: string) => void;
|
||||
loadRecentMediaItems: (id: string, limit: number) => void;
|
||||
onDeleteNicknameAndNote: () => void;
|
||||
onOpenEditNicknameAndNoteModal: () => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
|
||||
|
@ -150,7 +148,7 @@ type ActionProps = {
|
|||
onFailure?: () => unknown;
|
||||
}
|
||||
) => unknown;
|
||||
} & Pick<ConversationDetailsMediaListPropsType, 'showLightbox'>;
|
||||
};
|
||||
|
||||
export type Props = StateProps & ActionProps;
|
||||
|
||||
|
@ -180,6 +178,7 @@ export function ConversationDetails({
|
|||
conversation,
|
||||
deleteAvatarFromDisk,
|
||||
hasGroupLink,
|
||||
hasMedia,
|
||||
getPreferredBadge,
|
||||
getProfilesForConversation,
|
||||
groupsInCommon,
|
||||
|
@ -189,7 +188,6 @@ export function ConversationDetails({
|
|||
isGroup,
|
||||
isSignalConversation,
|
||||
leaveGroup,
|
||||
loadRecentMediaItems,
|
||||
memberships,
|
||||
maxGroupSize,
|
||||
maxRecommendedGroupSize,
|
||||
|
@ -211,7 +209,6 @@ export function ConversationDetails({
|
|||
setMuteExpiration,
|
||||
showContactModal,
|
||||
showConversation,
|
||||
showLightbox,
|
||||
startAvatarDownload,
|
||||
theme,
|
||||
toggleAboutContactModal,
|
||||
|
@ -691,6 +688,22 @@ export function ConversationDetails({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{hasMedia && (
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:ConversationDetailsMediaList--title')}
|
||||
icon={IconType.media}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:ConversationDetailsMediaList--title')}
|
||||
onClick={() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.AllMedia,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isGroup && !conversation.isMe && (
|
||||
<PanelRow
|
||||
onClick={() => toggleSafetyNumberModal(conversation.id)}
|
||||
|
@ -779,18 +792,6 @@ export function ConversationDetails({
|
|||
</PanelSection>
|
||||
)}
|
||||
|
||||
<ConversationDetailsMediaList
|
||||
conversation={conversation}
|
||||
i18n={i18n}
|
||||
loadRecentMediaItems={loadRecentMediaItems}
|
||||
showAllMedia={() =>
|
||||
pushPanelForConversation({
|
||||
type: PanelType.AllMedia,
|
||||
})
|
||||
}
|
||||
showLightbox={showLightbox}
|
||||
/>
|
||||
|
||||
{!isGroup && !conversation.isMe && !isSignalConversation && (
|
||||
<ConversationDetailsGroups
|
||||
contactId={conversation.id}
|
||||
|
|
|
@ -23,6 +23,7 @@ export enum IconType {
|
|||
'leave' = 'leave',
|
||||
'link' = 'link',
|
||||
'lock' = 'lock',
|
||||
'media' = 'media',
|
||||
'mention' = 'mention',
|
||||
'mute' = 'mute',
|
||||
'notifications' = 'notifications',
|
||||
|
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -4,11 +4,12 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent';
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import { MediaGridItem } from './MediaGridItem';
|
||||
import { missingCaseError } from '../../../util/missingCaseError';
|
||||
import { tw } from '../../../axo/tw';
|
||||
|
||||
export type Props = {
|
||||
header?: string;
|
||||
|
@ -16,6 +17,7 @@ export type Props = {
|
|||
mediaItems: ReadonlyArray<MediaItemType>;
|
||||
onItemClick: (event: ItemClickEvent) => unknown;
|
||||
type: 'media' | 'documents';
|
||||
theme?: ThemeType;
|
||||
};
|
||||
|
||||
export function AttachmentSection({
|
||||
|
@ -24,45 +26,66 @@ export function AttachmentSection({
|
|||
type,
|
||||
mediaItems,
|
||||
onItemClick,
|
||||
theme,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className="module-attachment-section">
|
||||
<h2 className="module-attachment-section__header">{header}</h2>
|
||||
<div className="module-attachment-section__items">
|
||||
{mediaItems.map((mediaItem, position, array) => {
|
||||
const shouldShowSeparator = position < array.length - 1;
|
||||
const { message, index, attachment } = mediaItem;
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return (
|
||||
<section className={tw('ps-5')}>
|
||||
<h2 className={tw('ps-1 pt-4 pb-2 font-semibold')}>{header}</h2>
|
||||
<div className={tw('flex flex-row flex-wrap gap-1 pb-1')}>
|
||||
{mediaItems.map(mediaItem => {
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
const onClick = () => {
|
||||
onItemClick({ type, message, attachment });
|
||||
};
|
||||
const onClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
onItemClick({ type, message, attachment });
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'media':
|
||||
return (
|
||||
<MediaGridItem
|
||||
key={`${message.id}-${index}`}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
case 'documents':
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<section
|
||||
className={tw(
|
||||
'px-6',
|
||||
'mb-3 border-b border-b-border-primary pb-3',
|
||||
'last:mb-0 last:border-b-0 last:pb-0'
|
||||
)}
|
||||
>
|
||||
<h2 className={tw('pt-1.5 pb-2 font-semibold')}>{header}</h2>
|
||||
<div>
|
||||
{mediaItems.map(mediaItem => {
|
||||
const { message, index, attachment } = mediaItem;
|
||||
|
||||
const onClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
onItemClick({ type, message, attachment });
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentListItem
|
||||
key={`${message.id}-${index}`}
|
||||
fileName={attachment.fileName}
|
||||
fileSize={attachment.size}
|
||||
shouldShowSeparator={shouldShowSeparator}
|
||||
mediaItem={mediaItem}
|
||||
onClick={onClick}
|
||||
timestamp={message.receivedAtMs || message.receivedAt}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,55 +6,22 @@ import { action } from '@storybook/addon-actions';
|
|||
import type { Meta } from '@storybook/react';
|
||||
import type { Props } from './DocumentListItem';
|
||||
import { DocumentListItem } from './DocumentListItem';
|
||||
import { createPreparedMediaItems, createRandomDocuments } from './utils/mocks';
|
||||
|
||||
export default {
|
||||
title: 'Components/Conversation/MediaGallery/DocumentListItem',
|
||||
argTypes: {
|
||||
timestamp: { control: { type: 'date' } },
|
||||
fileName: { control: { type: 'text' } },
|
||||
fileSize: { control: { type: 'number' } },
|
||||
shouldShowSeparator: { control: { type: 'boolean' } },
|
||||
},
|
||||
args: {
|
||||
timestamp: Date.now(),
|
||||
fileName: 'meow.jpg',
|
||||
fileSize: 1024 * 1000 * 2,
|
||||
shouldShowSeparator: false,
|
||||
onClick: action('onClick'),
|
||||
},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
export function Single(args: Props): JSX.Element {
|
||||
return <DocumentListItem {...args} />;
|
||||
}
|
||||
|
||||
export function Multiple(): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
fileName: 'meow.jpg',
|
||||
fileSize: 1024 * 1000 * 2,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
fileName: 'rickroll.mp4',
|
||||
fileSize: 1024 * 1000 * 8,
|
||||
timestamp: Date.now() - 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
fileName: 'kitten.gif',
|
||||
fileSize: 1024 * 1000 * 1.2,
|
||||
timestamp: Date.now() - 14 * 24 * 60 * 60 * 1000,
|
||||
shouldShowSeparator: false,
|
||||
},
|
||||
];
|
||||
const items = createPreparedMediaItems(createRandomDocuments);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map(item => (
|
||||
{items.map(mediaItem => (
|
||||
<DocumentListItem
|
||||
key={item.fileName}
|
||||
key={mediaItem.attachment.fileName}
|
||||
mediaItem={mediaItem}
|
||||
onClick={action('onClick')}
|
||||
{...item}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -2,54 +2,46 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import moment from 'moment';
|
||||
import { formatFileSize } from '../../../util/formatFileSize';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import { tw } from '../../../axo/tw';
|
||||
import { FileThumbnail } from '../../FileThumbnail';
|
||||
|
||||
export type Props = {
|
||||
// Required
|
||||
timestamp: number;
|
||||
mediaItem: MediaItemType;
|
||||
|
||||
// Optional
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
onClick?: () => void;
|
||||
shouldShowSeparator?: boolean;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export function DocumentListItem({
|
||||
shouldShowSeparator = true,
|
||||
fileName,
|
||||
fileSize,
|
||||
onClick,
|
||||
timestamp,
|
||||
}: Props): JSX.Element {
|
||||
export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element {
|
||||
const { attachment, message } = mediaItem;
|
||||
|
||||
const { fileName, size: fileSize } = attachment;
|
||||
|
||||
const timestamp = message.receivedAtMs || message.receivedAt;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-document-list-item',
|
||||
shouldShowSeparator ? 'module-document-list-item--with-separator' : null
|
||||
)}
|
||||
<button
|
||||
className={tw('flex w-full flex-row items-center gap-3 py-2')}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="module-document-list-item__content"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="module-document-list-item__icon" />
|
||||
<div className="module-document-list-item__metadata">
|
||||
<span className="module-document-list-item__file-name">
|
||||
{fileName}
|
||||
</span>
|
||||
<span className="module-document-list-item__file-size">
|
||||
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
|
||||
</span>
|
||||
<div className={tw('shrink-0')}>
|
||||
<FileThumbnail {...attachment} />
|
||||
</div>
|
||||
<div className={tw('grow overflow-hidden text-left')}>
|
||||
<h3 className={tw('truncate')}>{fileName}</h3>
|
||||
<div className={tw('type-body-small leading-4 text-label-secondary')}>
|
||||
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
|
||||
</div>
|
||||
<div className="module-document-list-item__date">
|
||||
{moment(timestamp).format('ddd, MMM D, Y')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
|
||||
{moment(timestamp).format('MMM D')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -34,10 +34,15 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
loadMoreMedia: action('loadMoreMedia'),
|
||||
saveAttachment: action('saveAttachment'),
|
||||
showLightbox: action('showLightbox'),
|
||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||
cancelAttachmentDownload: action('cancelAttachmentDownload'),
|
||||
});
|
||||
|
||||
export function Populated(): JSX.Element {
|
||||
const documents = createRandomDocuments(Date.now(), days(1)).slice(0, 1);
|
||||
const documents = createRandomDocuments(Date.now() - days(5), days(5)).slice(
|
||||
0,
|
||||
10
|
||||
);
|
||||
const media = createPreparedMediaItems(createRandomMedia);
|
||||
const props = createProps({ documents, media });
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react';
|
|||
import moment from 'moment';
|
||||
|
||||
import type { ItemClickEvent } from './types/ItemClickEvent';
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import type { SaveAttachmentActionCreatorType } from '../../../state/ducks/conversations';
|
||||
import { AttachmentSection } from './AttachmentSection';
|
||||
|
@ -34,10 +34,13 @@ export type Props = {
|
|||
loadMoreDocuments: (id: string) => unknown;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
saveAttachment: SaveAttachmentActionCreatorType;
|
||||
kickOffAttachmentDownload: (options: { messageId: string }) => void;
|
||||
cancelAttachmentDownload: (options: { messageId: string }) => void;
|
||||
showLightbox: (options: {
|
||||
attachment: AttachmentType;
|
||||
messageId: string;
|
||||
}) => void;
|
||||
theme?: ThemeType;
|
||||
};
|
||||
|
||||
const MONTH_FORMAT = 'MMMM YYYY';
|
||||
|
@ -48,11 +51,22 @@ function MediaSection({
|
|||
loading,
|
||||
media,
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
cancelAttachmentDownload,
|
||||
showLightbox,
|
||||
type,
|
||||
theme,
|
||||
}: Pick<
|
||||
Props,
|
||||
'documents' | 'i18n' | 'loading' | 'media' | 'saveAttachment' | 'showLightbox'
|
||||
| 'documents'
|
||||
| 'i18n'
|
||||
| 'theme'
|
||||
| 'loading'
|
||||
| 'media'
|
||||
| 'saveAttachment'
|
||||
| 'kickOffAttachmentDownload'
|
||||
| 'cancelAttachmentDownload'
|
||||
| 'showLightbox'
|
||||
> & { type: 'media' | 'documents' }): JSX.Element {
|
||||
const mediaItems = type === 'media' ? media : documents;
|
||||
|
||||
|
@ -107,6 +121,7 @@ function MediaSection({
|
|||
key={header}
|
||||
header={header}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
type={type}
|
||||
mediaItems={section.mediaItems}
|
||||
onItemClick={(event: ItemClickEvent) => {
|
||||
|
@ -117,10 +132,16 @@ function MediaSection({
|
|||
}
|
||||
|
||||
case 'media': {
|
||||
showLightbox({
|
||||
attachment: event.attachment,
|
||||
messageId: event.message.id,
|
||||
});
|
||||
if (event.attachment.url || event.attachment.incrementalUrl) {
|
||||
showLightbox({
|
||||
attachment: event.attachment,
|
||||
messageId: event.message.id,
|
||||
});
|
||||
} else if (event.attachment.pending) {
|
||||
cancelAttachmentDownload({ messageId: event.message.id });
|
||||
} else {
|
||||
kickOffAttachmentDownload({ messageId: event.message.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -147,6 +168,8 @@ export function MediaGallery({
|
|||
loadMoreMedia,
|
||||
media,
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
cancelAttachmentDownload,
|
||||
showLightbox,
|
||||
}: Props): JSX.Element {
|
||||
const focusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -264,6 +287,8 @@ export function MediaGallery({
|
|||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
type="media"
|
||||
/>
|
||||
)}
|
||||
|
@ -275,6 +300,8 @@ export function MediaGallery({
|
|||
media={media}
|
||||
saveAttachment={saveAttachment}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
type="documents"
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -4,9 +4,15 @@
|
|||
import * as React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import { StorybookThemeContext } from '../../../../.storybook/StorybookThemeContext';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import type { AttachmentType } from '../../../types/Attachment';
|
||||
import { stringToMIMEType } from '../../../types/MIME';
|
||||
import { SignalService } from '../../../protobuf';
|
||||
import {
|
||||
IMAGE_JPEG,
|
||||
VIDEO_MP4,
|
||||
APPLICATION_OCTET_STREAM,
|
||||
type MIMEType,
|
||||
} from '../../../types/MIME';
|
||||
import type { Props } from './MediaGridItem';
|
||||
import { MediaGridItem } from './MediaGridItem';
|
||||
|
||||
|
@ -18,21 +24,37 @@ export default {
|
|||
|
||||
const createProps = (
|
||||
overrideProps: Partial<Props> & { mediaItem: MediaItemType }
|
||||
): Props => ({
|
||||
i18n,
|
||||
mediaItem: overrideProps.mediaItem,
|
||||
onClick: action('onClick'),
|
||||
});
|
||||
): Props => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const theme = React.useContext(StorybookThemeContext);
|
||||
|
||||
return {
|
||||
i18n,
|
||||
theme,
|
||||
mediaItem: overrideProps.mediaItem,
|
||||
onClick: action('onClick'),
|
||||
};
|
||||
};
|
||||
|
||||
type OverridePropsMediaItemType = Partial<MediaItemType> & {
|
||||
objectURL?: string;
|
||||
contentType?: MIMEType;
|
||||
};
|
||||
|
||||
const createMediaItem = (
|
||||
overrideProps: Partial<MediaItemType> = {}
|
||||
overrideProps: OverridePropsMediaItemType
|
||||
): MediaItemType => ({
|
||||
thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '',
|
||||
contentType: overrideProps.contentType || stringToMIMEType(''),
|
||||
index: 0,
|
||||
attachment: {} as AttachmentType, // attachment not useful in the component
|
||||
attachment: overrideProps.attachment || {
|
||||
path: '123',
|
||||
contentType: overrideProps.contentType ?? IMAGE_JPEG,
|
||||
size: 123,
|
||||
url: overrideProps.objectURL,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
isPermanentlyUndownloadable: false,
|
||||
},
|
||||
message: {
|
||||
attachments: [],
|
||||
type: 'incoming',
|
||||
conversationId: '1234',
|
||||
id: 'id',
|
||||
receivedAt: Date.now(),
|
||||
|
@ -43,8 +65,34 @@ const createMediaItem = (
|
|||
|
||||
export function Image(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
thumbnailObjectUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
objectURL: '/fixtures/kitten-1-64-64.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
mediaItem,
|
||||
});
|
||||
|
||||
return <MediaGridItem {...props} />;
|
||||
}
|
||||
|
||||
export function WideImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
objectURL: '/fixtures/wide.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
mediaItem,
|
||||
});
|
||||
|
||||
return <MediaGridItem {...props} />;
|
||||
}
|
||||
|
||||
export function TallImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
objectURL: '/fixtures/snow.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -56,8 +104,42 @@ export function Image(): JSX.Element {
|
|||
|
||||
export function Video(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
thumbnailObjectUrl: '/fixtures/kitten-2-64-64.jpg',
|
||||
contentType: stringToMIMEType('video/mp4'),
|
||||
attachment: {
|
||||
incrementalUrl: 'abc',
|
||||
screenshot: {
|
||||
url: '/fixtures/kitten-2-64-64.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
},
|
||||
contentType: VIDEO_MP4,
|
||||
size: 1024,
|
||||
isPermanentlyUndownloadable: false,
|
||||
path: 'abcd',
|
||||
},
|
||||
contentType: VIDEO_MP4,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
mediaItem,
|
||||
});
|
||||
|
||||
return <MediaGridItem {...props} />;
|
||||
}
|
||||
|
||||
export function GIF(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
attachment: {
|
||||
url: 'abc',
|
||||
screenshot: {
|
||||
url: '/fixtures/kitten-2-64-64.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
},
|
||||
contentType: VIDEO_MP4,
|
||||
size: 1024,
|
||||
isPermanentlyUndownloadable: false,
|
||||
path: 'abcd',
|
||||
flags: SignalService.AttachmentPointer.Flags.GIF,
|
||||
},
|
||||
contentType: VIDEO_MP4,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -69,7 +151,53 @@ export function Video(): JSX.Element {
|
|||
|
||||
export function MissingImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
contentType: IMAGE_JPEG,
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
size: 123,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
isPermanentlyUndownloadable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
mediaItem,
|
||||
});
|
||||
|
||||
return <MediaGridItem {...props} />;
|
||||
}
|
||||
|
||||
export function PendingImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
size: 123000,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
isPermanentlyUndownloadable: false,
|
||||
totalDownloaded: 0,
|
||||
pending: true,
|
||||
},
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
mediaItem,
|
||||
});
|
||||
|
||||
return <MediaGridItem {...props} />;
|
||||
}
|
||||
|
||||
export function DownloadingImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
contentType: IMAGE_JPEG,
|
||||
attachment: {
|
||||
contentType: IMAGE_JPEG,
|
||||
size: 123000,
|
||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||
isPermanentlyUndownloadable: false,
|
||||
totalDownloaded: 20300,
|
||||
pending: true,
|
||||
},
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -81,7 +209,7 @@ export function MissingImage(): JSX.Element {
|
|||
|
||||
export function MissingVideo(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
contentType: stringToMIMEType('video/mp4'),
|
||||
contentType: VIDEO_MP4,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -93,8 +221,8 @@ export function MissingVideo(): JSX.Element {
|
|||
|
||||
export function BrokenImage(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
thumbnailObjectUrl: '/missing-fixtures/nope.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
objectURL: '/missing-fixtures/nope.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -106,8 +234,8 @@ export function BrokenImage(): JSX.Element {
|
|||
|
||||
export function BrokenVideo(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
thumbnailObjectUrl: '/missing-fixtures/nope.mp4',
|
||||
contentType: stringToMIMEType('video/mp4'),
|
||||
objectURL: '/missing-fixtures/nope.mp4',
|
||||
contentType: VIDEO_MP4,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
@ -119,7 +247,7 @@ export function BrokenVideo(): JSX.Element {
|
|||
|
||||
export function OtherContentType(): JSX.Element {
|
||||
const mediaItem = createMediaItem({
|
||||
contentType: stringToMIMEType('application/text'),
|
||||
contentType: APPLICATION_OCTET_STREAM,
|
||||
});
|
||||
|
||||
const props = createProps({
|
||||
|
|
|
@ -1,105 +1,167 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import {
|
||||
isImageTypeSupported,
|
||||
isVideoTypeSupported,
|
||||
} from '../../../util/GoogleChrome';
|
||||
import type { LocalizerType } from '../../../types/Util';
|
||||
import { formatFileSize } from '../../../util/formatFileSize';
|
||||
import type { LocalizerType, ThemeType } from '../../../types/Util';
|
||||
import type { MediaItemType } from '../../../types/MediaItem';
|
||||
import { createLogger } from '../../../logging/log';
|
||||
import type { AttachmentForUIType } from '../../../types/Attachment';
|
||||
import {
|
||||
getAlt,
|
||||
getUrl,
|
||||
defaultBlurHash,
|
||||
isGIF,
|
||||
} from '../../../types/Attachment';
|
||||
import { ImageOrBlurhash } from '../../ImageOrBlurhash';
|
||||
import { SpinnerV2 } from '../../SpinnerV2';
|
||||
import { tw } from '../../../axo/tw';
|
||||
import { AxoSymbol } from '../../../axo/AxoSymbol';
|
||||
|
||||
const log = createLogger('MediaGridItem');
|
||||
|
||||
export type Props = {
|
||||
export type Props = Readonly<{
|
||||
mediaItem: ReadonlyDeep<MediaItemType>;
|
||||
onClick?: () => void;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
theme?: ThemeType;
|
||||
}>;
|
||||
|
||||
function MediaGridItemContent(props: Props) {
|
||||
const { mediaItem, i18n } = props;
|
||||
const { attachment, contentType } = mediaItem;
|
||||
export function MediaGridItem(props: Props): JSX.Element {
|
||||
const {
|
||||
mediaItem: { attachment },
|
||||
i18n,
|
||||
theme,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
const [imageBroken, setImageBroken] = useState(false);
|
||||
const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
|
||||
const url = getUrl(attachment);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
log.info('Image failed to load; failing over to placeholder');
|
||||
setImageBroken(true);
|
||||
}, []);
|
||||
const { width, height } = attachment;
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
const imageOrBlurHash = (
|
||||
<ImageOrBlurhash
|
||||
className={tw('object-cover')}
|
||||
src={url}
|
||||
intrinsicWidth={width}
|
||||
intrinsicHeight={height}
|
||||
alt={getAlt(attachment, i18n)}
|
||||
blurHash={resolvedBlurHash}
|
||||
/>
|
||||
);
|
||||
|
||||
let label: string;
|
||||
if (attachment.url || attachment.incrementalUrl) {
|
||||
label = i18n('icu:imageOpenAlt');
|
||||
} else if (attachment.pending) {
|
||||
label = i18n('icu:cancelDownload');
|
||||
} else {
|
||||
label = i18n('icu:startDownload');
|
||||
}
|
||||
|
||||
if (contentType && isImageTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-image'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={tw(
|
||||
'relative size-30 overflow-hidden rounded-md',
|
||||
'flex items-center justify-center'
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
{imageOrBlurHash}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
);
|
||||
<MetadataOverlay i18n={i18n} attachment={attachment} />
|
||||
<SpinnerOverlay attachment={attachment} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type SpinnerOverlayProps = Readonly<{
|
||||
attachment: AttachmentForUIType;
|
||||
}>;
|
||||
|
||||
function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
|
||||
const { attachment } = props;
|
||||
|
||||
if (attachment.url != null || attachment.incrementalUrl != null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (contentType && isVideoTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-video'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const spinnerValue =
|
||||
(!attachment.incrementalUrl &&
|
||||
attachment.size &&
|
||||
attachment.totalDownloaded) ||
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<div className="module-media-grid-item__image-container">
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={handleImageError}
|
||||
return (
|
||||
<div
|
||||
className={tw(
|
||||
'absolute size-12.5 rounded-full bg-fill-on-media',
|
||||
'flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
{attachment.pending && (
|
||||
<SpinnerV2
|
||||
variant="no-background"
|
||||
size={44}
|
||||
strokeWidth={2}
|
||||
marginRatio={1}
|
||||
min={0}
|
||||
max={attachment.size}
|
||||
value={spinnerValue}
|
||||
/>
|
||||
)}
|
||||
<div className={tw('absolute text-label-primary-on-color')}>
|
||||
<AxoSymbol.Icon
|
||||
symbol={attachment.pending ? 'x' : 'arrow-down'}
|
||||
size={24}
|
||||
label={null}
|
||||
/>
|
||||
<div className="module-media-grid-item__circle-overlay">
|
||||
<div className="module-media-grid-item__play-overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MetadataOverlayProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
attachment: AttachmentForUIType;
|
||||
}>;
|
||||
|
||||
function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined {
|
||||
const { i18n, attachment } = props;
|
||||
|
||||
const canBeShown =
|
||||
attachment.url != null || attachment.incrementalUrl != null;
|
||||
if (canBeShown && !isGIF([attachment])) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (isGIF([attachment]) && canBeShown) {
|
||||
text = i18n('icu:message--getNotificationText--gif');
|
||||
} else {
|
||||
text = formatFileSize(attachment.size);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-generic'
|
||||
className={tw(
|
||||
'absolute end-0 bottom-0 h-11.5 w-full',
|
||||
// This is an overlay gradient to ensure that the text has contrast
|
||||
// against the image/blurhash.
|
||||
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
|
||||
'bg-linear-to-b from-transparent to-[rgba(0,0,0,0.6)]'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaGridItem(props: Props): JSX.Element {
|
||||
const { onClick } = props;
|
||||
return (
|
||||
<button type="button" className="module-media-grid-item" onClick={onClick}>
|
||||
<MediaGridItemContent {...props} />
|
||||
</button>
|
||||
>
|
||||
<span
|
||||
className={tw(
|
||||
'absolute end-2 bottom-1.5',
|
||||
'type-caption text-[12px] text-label-primary-on-color'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { random, range, sample, sortBy } from 'lodash';
|
||||
import type { MIMEType } from '../../../../types/MIME';
|
||||
import { type MIMEType, IMAGE_JPEG } from '../../../../types/MIME';
|
||||
import type { MediaItemType } from '../../../../types/MediaItem';
|
||||
import { randomBlurHash } from '../../../../util/randomBlurHash';
|
||||
import { SignalService } from '../../../../protobuf';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
export const days = (n: number): number => n * DAY_MS;
|
||||
|
@ -16,6 +18,7 @@ const contentTypes = {
|
|||
mp4: 'video/mp4',
|
||||
docx: 'application/text',
|
||||
pdf: 'application/pdf',
|
||||
exe: 'application/exe',
|
||||
txt: 'application/text',
|
||||
} as unknown as Record<string, MIMEType>;
|
||||
|
||||
|
@ -27,27 +30,42 @@ function createRandomFile(
|
|||
const contentType = contentTypes[fileExtension];
|
||||
const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`;
|
||||
|
||||
const isDownloaded = Math.random() > 0.4;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
message: {
|
||||
conversationId: '123',
|
||||
type: 'incoming',
|
||||
id: random(Date.now()).toString(),
|
||||
receivedAt: Math.floor(Math.random() * 10),
|
||||
receivedAtMs: random(startTime, startTime + timeWindow),
|
||||
attachments: [],
|
||||
sentAt: Date.now(),
|
||||
},
|
||||
attachment: {
|
||||
url: '',
|
||||
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
||||
path: isDownloaded ? 'abc' : undefined,
|
||||
screenshot:
|
||||
fileExtension === 'mp4'
|
||||
? {
|
||||
url: isDownloaded
|
||||
? '/fixtures/cat-screenshot-3x4.png'
|
||||
: undefined,
|
||||
contentType: IMAGE_JPEG,
|
||||
}
|
||||
: undefined,
|
||||
flags:
|
||||
fileExtension === 'mp4' && Math.random() > 0.5
|
||||
? SignalService.AttachmentPointer.Flags.GIF
|
||||
: 0,
|
||||
width: 400,
|
||||
height: 300,
|
||||
fileName,
|
||||
size: random(1000, 1000 * 1000 * 50),
|
||||
contentType,
|
||||
blurHash: randomBlurHash(),
|
||||
isPermanentlyUndownloadable: false,
|
||||
},
|
||||
index: 0,
|
||||
thumbnailObjectUrl: `https://placekitten.com/${random(50, 150)}/${random(
|
||||
50,
|
||||
150
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -64,7 +82,12 @@ export function createRandomDocuments(
|
|||
startTime: number,
|
||||
timeWindow: number
|
||||
): Array<MediaItemType> {
|
||||
return createRandomFiles(startTime, timeWindow, ['docx', 'pdf', 'txt']);
|
||||
return createRandomFiles(startTime, timeWindow, [
|
||||
'docx',
|
||||
'pdf',
|
||||
'exe',
|
||||
'txt',
|
||||
]);
|
||||
}
|
||||
|
||||
export function createRandomMedia(
|
||||
|
|
|
@ -50,6 +50,8 @@ import type {
|
|||
} from '../types/GroupSendEndorsements';
|
||||
import type { SyncTaskType } from '../util/syncTasks';
|
||||
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { MediaItemMessageType } from '../types/MediaItem';
|
||||
import type { GifType } from '../components/fun/panels/FunPanelGifs';
|
||||
import type { NotificationProfileType } from '../types/NotificationProfile';
|
||||
import type { DonationReceipt } from '../types/Donations';
|
||||
|
@ -566,9 +568,26 @@ export type BackupAttachmentDownloadProgress = {
|
|||
completedBytes: number;
|
||||
};
|
||||
|
||||
export type GetOlderMediaOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
limit: number;
|
||||
messageId?: string;
|
||||
receivedAt?: number;
|
||||
sentAt?: number;
|
||||
}>;
|
||||
|
||||
export type MediaItemDBType = Readonly<{
|
||||
attachment: AttachmentType;
|
||||
index: number;
|
||||
message: MediaItemMessageType;
|
||||
}>;
|
||||
|
||||
export const MESSAGE_ATTACHMENT_COLUMNS = [
|
||||
'messageId',
|
||||
'conversationId',
|
||||
'messageType',
|
||||
'receivedAt',
|
||||
'receivedAtMs',
|
||||
'sentAt',
|
||||
'attachmentType',
|
||||
'orderInMessage',
|
||||
|
@ -612,6 +631,7 @@ export const MESSAGE_ATTACHMENT_COLUMNS = [
|
|||
'storyTextAttachmentJson',
|
||||
'localBackupPath',
|
||||
'isCorrupted',
|
||||
'isViewOnce',
|
||||
'backfillError',
|
||||
'error',
|
||||
'wasTooBig',
|
||||
|
@ -626,6 +646,9 @@ export type MessageAttachmentDBType = {
|
|||
orderInMessage: number;
|
||||
editHistoryIndex: number | null;
|
||||
conversationId: string;
|
||||
messageType: string;
|
||||
receivedAt: number;
|
||||
receivedAtMs: number | null;
|
||||
sentAt: number;
|
||||
clientUuid: string | null;
|
||||
size: number;
|
||||
|
@ -667,6 +690,7 @@ export type MessageAttachmentDBType = {
|
|||
storyTextAttachmentJson: string | null;
|
||||
localBackupPath: string | null;
|
||||
isCorrupted: 1 | 0 | null;
|
||||
isViewOnce: 1 | 0 | null;
|
||||
backfillError: 1 | 0 | null;
|
||||
error: 1 | 0 | null;
|
||||
wasTooBig: 1 | 0 | null;
|
||||
|
@ -766,6 +790,8 @@ type ReadableInterface = {
|
|||
maxTimestamp: number
|
||||
) => Array<MessageType>;
|
||||
// getOlderMessagesByConversation is JSON on server, full message on Client
|
||||
hasMedia: (conversationId: string) => boolean;
|
||||
getOlderMedia: (options: GetOlderMediaOptionsType) => Array<MediaItemDBType>;
|
||||
getAllStories: (options: {
|
||||
conversationId?: string;
|
||||
sourceServiceId?: ServiceIdString;
|
||||
|
|
148
ts/sql/Server.ts
148
ts/sql/Server.ts
|
@ -88,6 +88,7 @@ import {
|
|||
import {
|
||||
hydrateMessage,
|
||||
hydrateMessages,
|
||||
convertAttachmentDBFieldsToAttachmentType,
|
||||
getAttachmentReferencesForMessages,
|
||||
ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
|
||||
} from './hydration';
|
||||
|
@ -148,10 +149,12 @@ import type {
|
|||
GetConversationRangeCenteredOnMessageResultType,
|
||||
GetKnownMessageAttachmentsResultType,
|
||||
GetNearbyMessageFromDeletedSetOptionsType,
|
||||
GetOlderMediaOptionsType,
|
||||
GetRecentStoryRepliesOptionsType,
|
||||
GetUnreadByConversationAndMarkReadResultType,
|
||||
IdentityKeyIdType,
|
||||
ItemKeyType,
|
||||
MediaItemDBType,
|
||||
MessageAttachmentsCursorType,
|
||||
MessageCursorType,
|
||||
MessageMetricsType,
|
||||
|
@ -434,6 +437,9 @@ export const DataReader: ServerReadableInterface = {
|
|||
getCallHistoryGroups,
|
||||
hasGroupCallHistoryMessage,
|
||||
|
||||
hasMedia,
|
||||
getOlderMedia,
|
||||
|
||||
getAllNotificationProfiles,
|
||||
getNotificationProfileById,
|
||||
|
||||
|
@ -2481,17 +2487,29 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
conversationId: string;
|
||||
sent_at: number;
|
||||
} & Pick<
|
||||
MessageAttributesType,
|
||||
MessageType,
|
||||
| 'attachments'
|
||||
| 'bodyAttachment'
|
||||
| 'contact'
|
||||
| 'preview'
|
||||
| 'quote'
|
||||
| 'type'
|
||||
| 'sticker'
|
||||
| 'isViewOnce'
|
||||
| 'received_at'
|
||||
| 'received_at_ms'
|
||||
>,
|
||||
{ editHistoryIndex }: { editHistoryIndex: number | null }
|
||||
) {
|
||||
const { id: messageId, conversationId, sent_at: sentAt } = message;
|
||||
const {
|
||||
id: messageId,
|
||||
type: messageType,
|
||||
conversationId,
|
||||
sent_at: sentAt,
|
||||
received_at: receivedAt,
|
||||
received_at_ms: receivedAtMs,
|
||||
isViewOnce,
|
||||
} = message;
|
||||
|
||||
const mainAttachments = message.attachments;
|
||||
if (mainAttachments) {
|
||||
|
@ -2500,12 +2518,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'attachment',
|
||||
attachment,
|
||||
orderInMessage: i,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2515,12 +2537,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'long-message',
|
||||
attachment: bodyAttachment,
|
||||
orderInMessage: 0,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2534,12 +2560,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'preview',
|
||||
attachment,
|
||||
orderInMessage: i,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2554,12 +2584,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'quote',
|
||||
attachment: attachment.thumbnail,
|
||||
orderInMessage: i,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2576,12 +2610,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'contact',
|
||||
attachment,
|
||||
orderInMessage: i,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2591,12 +2629,16 @@ function saveMessageAttachmentsForRootOrEditedVersion(
|
|||
saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType: 'sticker',
|
||||
attachment: stickerAttachment,
|
||||
orderInMessage: 0,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2620,8 +2662,10 @@ function saveMessageAttachments(
|
|||
db,
|
||||
{
|
||||
id: message.id,
|
||||
type: message.type,
|
||||
conversationId: message.conversationId,
|
||||
sent_at: editHistory.timestamp,
|
||||
isViewOnce: message.isViewOnce,
|
||||
...editHistory,
|
||||
},
|
||||
{ editHistoryIndex: idx }
|
||||
|
@ -2632,30 +2676,41 @@ function saveMessageAttachments(
|
|||
function saveMessageAttachment({
|
||||
db,
|
||||
messageId,
|
||||
messageType,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
attachmentType,
|
||||
attachment,
|
||||
orderInMessage,
|
||||
editHistoryIndex,
|
||||
isViewOnce,
|
||||
}: {
|
||||
db: WritableDB;
|
||||
messageId: string;
|
||||
messageType: string;
|
||||
conversationId: string;
|
||||
sentAt: number;
|
||||
receivedAt: number;
|
||||
receivedAtMs: number | undefined;
|
||||
attachmentType: AttachmentDownloadJobTypeType;
|
||||
attachment: AttachmentType;
|
||||
orderInMessage: number;
|
||||
editHistoryIndex: number | null;
|
||||
isViewOnce: boolean | undefined;
|
||||
}) {
|
||||
const unparsedValues: ShallowNullToUndefined<MessageAttachmentDBType> = {
|
||||
messageId,
|
||||
messageType,
|
||||
editHistoryIndex:
|
||||
editHistoryIndex ?? ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX,
|
||||
attachmentType,
|
||||
orderInMessage,
|
||||
conversationId,
|
||||
sentAt,
|
||||
receivedAt,
|
||||
receivedAtMs,
|
||||
clientUuid: attachment.clientUuid,
|
||||
size: attachment.size,
|
||||
contentType: attachment.contentType,
|
||||
|
@ -2700,6 +2755,7 @@ function saveMessageAttachment({
|
|||
wasTooBig: convertOptionalBooleanToInteger(attachment.wasTooBig),
|
||||
backfillError: convertOptionalBooleanToInteger(attachment.backfillError),
|
||||
isCorrupted: convertOptionalBooleanToInteger(attachment.isCorrupted),
|
||||
isViewOnce: convertOptionalBooleanToInteger(isViewOnce),
|
||||
copiedFromQuotedAttachment:
|
||||
'copied' in attachment
|
||||
? convertOptionalBooleanToInteger(attachment.copied)
|
||||
|
@ -5005,6 +5061,94 @@ function hasGroupCallHistoryMessage(
|
|||
return exists === 1;
|
||||
}
|
||||
|
||||
function hasMedia(db: ReadableDB, conversationId: string): boolean {
|
||||
const [query, params] = sql`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM message_attachments
|
||||
INDEXED BY message_attachments_getOlderMedia
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
editHistoryIndex IS -1 AND
|
||||
attachmentType IS 'attachment' AND
|
||||
messageType IN ('incoming', 'outgoing') AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
contentType IS NOT NULL AND
|
||||
contentType IS NOT '' AND
|
||||
contentType IS NOT 'text/x-signal-plain' AND
|
||||
contentType NOT LIKE 'audio/%'
|
||||
);
|
||||
`;
|
||||
const exists = db.prepare(query, { pluck: true }).get<number>(params);
|
||||
|
||||
return exists === 1;
|
||||
}
|
||||
|
||||
function getOlderMedia(
|
||||
db: ReadableDB,
|
||||
{
|
||||
conversationId,
|
||||
limit,
|
||||
messageId,
|
||||
receivedAt: maxReceivedAt = Number.MAX_VALUE,
|
||||
sentAt: maxSentAt = Number.MAX_VALUE,
|
||||
}: GetOlderMediaOptionsType
|
||||
): Array<MediaItemDBType> {
|
||||
const timeFilters = {
|
||||
first: sqlFragment`receivedAt = ${maxReceivedAt} AND sentAt < ${maxSentAt}`,
|
||||
second: sqlFragment`receivedAt < ${maxReceivedAt}`,
|
||||
};
|
||||
|
||||
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
|
||||
SELECT
|
||||
*
|
||||
FROM message_attachments
|
||||
INDEXED BY message_attachments_getOlderMedia
|
||||
WHERE
|
||||
conversationId IS ${conversationId} AND
|
||||
editHistoryIndex IS -1 AND
|
||||
attachmentType IS 'attachment' AND
|
||||
(
|
||||
${timeFilter}
|
||||
) AND
|
||||
(
|
||||
-- see 'isVisualMedia' in ts/types/Attachment.ts
|
||||
contentType LIKE 'image/%' OR
|
||||
contentType LIKE 'video/%'
|
||||
) AND
|
||||
isViewOnce IS NOT 1 AND
|
||||
messageType IN ('incoming', 'outgoing') AND
|
||||
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
|
||||
ORDER BY receivedAt DESC, sentAt DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const [query, params] = sql`
|
||||
SELECT first.* FROM (${createQuery(timeFilters.first)}) as first
|
||||
UNION ALL
|
||||
SELECT second.* FROM (${createQuery(timeFilters.second)}) as second
|
||||
`;
|
||||
|
||||
const results: Array<MessageAttachmentDBType> = db.prepare(query).all(params);
|
||||
|
||||
return results.map(attachment => {
|
||||
const { orderInMessage, messageType, sentAt, receivedAt, receivedAtMs } =
|
||||
attachment;
|
||||
|
||||
return {
|
||||
message: {
|
||||
id: attachment.messageId,
|
||||
type: messageType as 'incoming' | 'outgoing',
|
||||
conversationId,
|
||||
receivedAt,
|
||||
receivedAtMs: receivedAtMs ?? undefined,
|
||||
sentAt,
|
||||
},
|
||||
index: orderInMessage,
|
||||
attachment: convertAttachmentDBFieldsToAttachmentType(attachment),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function _markCallHistoryMissed(
|
||||
db: WritableDB,
|
||||
callIds: ReadonlyArray<string>
|
||||
|
|
|
@ -330,7 +330,7 @@ function hydrateMessageRootOrRevisionWithAttachments<
|
|||
return hydratedMessage;
|
||||
}
|
||||
|
||||
function convertAttachmentDBFieldsToAttachmentType(
|
||||
export function convertAttachmentDBFieldsToAttachmentType(
|
||||
dbFields: MessageAttachmentDBType
|
||||
): AttachmentType {
|
||||
const messageAttachment = shallowDropNull(dbFields);
|
||||
|
|
43
ts/sql/migrations/1450-all-media.ts
Normal file
43
ts/sql/migrations/1450-all-media.ts
Normal 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
|
||||
`);
|
||||
}
|
|
@ -120,6 +120,7 @@ import updateToSchemaVersion1410 from './1410-remove-wallpaper';
|
|||
import updateToSchemaVersion1420 from './1420-backup-downloads';
|
||||
import updateToSchemaVersion1430 from './1430-call-links-epoch-id';
|
||||
import updateToSchemaVersion1440 from './1440-chat-folders';
|
||||
import updateToSchemaVersion1450 from './1450-all-media';
|
||||
|
||||
import { DataWriter } from '../Server';
|
||||
|
||||
|
@ -1595,6 +1596,7 @@ export const SCHEMA_VERSIONS: ReadonlyArray<SchemaUpdateType> = [
|
|||
{ version: 1420, update: updateToSchemaVersion1420 },
|
||||
{ version: 1430, update: updateToSchemaVersion1430 },
|
||||
{ version: 1440, update: updateToSchemaVersion1440 },
|
||||
{ version: 1450, update: updateToSchemaVersion1450 },
|
||||
];
|
||||
|
||||
export class DBVersionFromFutureError extends Error {
|
||||
|
|
|
@ -33,15 +33,18 @@ const permissiveOptionalBool = z
|
|||
export const permissiveMessageAttachmentSchema = z.object({
|
||||
// Fields required to be NOT NULL
|
||||
messageId: z.string(),
|
||||
messageType: z.string(),
|
||||
editHistoryIndex: z.number(),
|
||||
attachmentType: attachmentDownloadTypeSchema,
|
||||
orderInMessage: z.number(),
|
||||
conversationId: z.string(),
|
||||
sentAt: z.number().catch(0),
|
||||
receivedAt: z.number().catch(0),
|
||||
size: z.number().catch(0),
|
||||
contentType: z.string().catch(APPLICATION_OCTET_STREAM),
|
||||
|
||||
// Fields allowing NULL
|
||||
receivedAtMs: permissiveNumberOrNull,
|
||||
path: permissiveStringOrNull,
|
||||
clientUuid: permissiveStringOrNull,
|
||||
localKey: permissiveStringOrNull,
|
||||
|
@ -82,6 +85,7 @@ export const permissiveMessageAttachmentSchema = z.object({
|
|||
wasTooBig: permissiveOptionalBool,
|
||||
backfillError: permissiveOptionalBool,
|
||||
isCorrupted: permissiveOptionalBool,
|
||||
isViewOnce: permissiveOptionalBool,
|
||||
copiedFromQuotedAttachment: permissiveOptionalBool,
|
||||
version: permissiveAttachmentVersion,
|
||||
pending: permissiveOptionalBool,
|
||||
|
|
|
@ -60,7 +60,6 @@ import type {
|
|||
ConversationAttributesType,
|
||||
DraftEditMessageType,
|
||||
LastMessageStatus,
|
||||
MessageAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
} from '../../model-types.d';
|
||||
import type {
|
||||
|
@ -215,7 +214,6 @@ import {
|
|||
} from '../../messages/MessageSendState';
|
||||
import { markFailed } from '../../test-node/util/messageFailures';
|
||||
import { cleanupMessages } from '../../util/cleanup';
|
||||
import { MessageModel } from '../../models/messages';
|
||||
import type { ConversationModel } from '../../models/conversations';
|
||||
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent';
|
||||
|
||||
|
@ -1169,7 +1167,6 @@ export const actions = {
|
|||
loadNewerMessages,
|
||||
loadNewestMessages,
|
||||
loadOlderMessages,
|
||||
loadRecentMediaItems,
|
||||
markAttachmentAsCorrupted,
|
||||
markMessageRead,
|
||||
markOpenConversationRead,
|
||||
|
@ -3996,73 +3993,6 @@ function initiateMigrationToGroupV2(conversationId: string): NoopActionType {
|
|||
};
|
||||
}
|
||||
|
||||
function loadRecentMediaItems(
|
||||
conversationId: string,
|
||||
limit: number
|
||||
): ThunkAction<void, RootStateType, unknown, SetRecentMediaItemsActionType> {
|
||||
return async dispatch => {
|
||||
const messages: Array<MessageAttributesType> =
|
||||
await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
limit,
|
||||
requireVisualMediaAttachments: true,
|
||||
storyId: undefined,
|
||||
includeStoryReplies: false,
|
||||
});
|
||||
|
||||
// Cache these messages in memory to ensure Lightbox can find them
|
||||
messages.forEach(message => {
|
||||
window.MessageCache.register(new MessageModel(message));
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
const recentMediaItems = messages
|
||||
.filter(message => message.attachments !== undefined)
|
||||
.reduce(
|
||||
(acc, message) => [
|
||||
...acc,
|
||||
...(message.attachments || []).map(
|
||||
(attachment: AttachmentType): MediaItemType => {
|
||||
const { thumbnail } = attachment;
|
||||
|
||||
const result = {
|
||||
objectURL: attachment.path
|
||||
? getLocalAttachmentUrl(attachment)
|
||||
: '',
|
||||
thumbnailObjectUrl: thumbnail?.path
|
||||
? getLocalAttachmentUrl(thumbnail)
|
||||
: '',
|
||||
contentType: attachment.contentType,
|
||||
index,
|
||||
attachment,
|
||||
message: {
|
||||
attachments: message.attachments || [],
|
||||
conversationId:
|
||||
window.ConversationController.get(message.sourceServiceId)
|
||||
?.id || message.conversationId,
|
||||
id: message.id,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: Number(message.received_at_ms),
|
||||
sentAt: message.sent_at,
|
||||
},
|
||||
};
|
||||
|
||||
index += 1;
|
||||
|
||||
return result;
|
||||
}
|
||||
),
|
||||
],
|
||||
[] as Array<MediaItemType>
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_RECENT_MEDIA_ITEMS',
|
||||
payload: { id: conversationId, recentMediaItems },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type SaveAttachmentActionCreatorType = ReadonlyDeep<
|
||||
(attachment: AttachmentType, timestamp?: number, index?: number) => unknown
|
||||
>;
|
||||
|
|
|
@ -21,7 +21,6 @@ import { getMessageById } from '../../messages/getMessageById';
|
|||
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
|
||||
import {
|
||||
getUndownloadedAttachmentSignature,
|
||||
isGIF,
|
||||
isIncremental,
|
||||
} from '../../types/Attachment';
|
||||
import {
|
||||
|
@ -32,7 +31,7 @@ import {
|
|||
getLocalAttachmentUrl,
|
||||
AttachmentDisposition,
|
||||
} from '../../util/getLocalAttachmentUrl';
|
||||
import { isTapToView } from '../selectors/message';
|
||||
import { isTapToView, getPropsForAttachment } from '../selectors/message';
|
||||
import { SHOW_TOAST } from './toast';
|
||||
import { ToastType } from '../../types/Toast';
|
||||
import {
|
||||
|
@ -202,25 +201,26 @@ function showLightboxForViewOnceMedia(
|
|||
const { path: tempPath } =
|
||||
await copyAttachmentIntoTempDirectory(absolutePath);
|
||||
const tempAttachment = {
|
||||
...firstAttachment,
|
||||
...getPropsForAttachment(
|
||||
firstAttachment,
|
||||
'attachment',
|
||||
message.attributes
|
||||
),
|
||||
path: tempPath,
|
||||
};
|
||||
tempAttachment.url = getLocalAttachmentUrl(tempAttachment, {
|
||||
disposition: AttachmentDisposition.Temporary,
|
||||
});
|
||||
|
||||
await markViewOnceMessageViewed(message);
|
||||
|
||||
const { contentType } = tempAttachment;
|
||||
|
||||
const media = [
|
||||
{
|
||||
attachment: tempAttachment,
|
||||
objectURL: getLocalAttachmentUrl(tempAttachment, {
|
||||
disposition: AttachmentDisposition.Temporary,
|
||||
}),
|
||||
contentType,
|
||||
index: 0,
|
||||
message: {
|
||||
attachments: message.get('attachments') || [],
|
||||
id: message.get('id'),
|
||||
type: message.get('type'),
|
||||
conversationId: message.get('conversationId'),
|
||||
receivedAt: message.get('received_at'),
|
||||
receivedAtMs: Number(message.get('received_at_ms')),
|
||||
|
@ -307,7 +307,6 @@ function showLightbox(opts: {
|
|||
}
|
||||
|
||||
const attachments = filterValidAttachments(message.attributes);
|
||||
const loop = isGIF(attachments);
|
||||
|
||||
const authorId =
|
||||
window.ConversationController.lookupOrCreate({
|
||||
|
@ -320,33 +319,25 @@ function showLightbox(opts: {
|
|||
|
||||
const media = attachments
|
||||
.map((item, index) => ({
|
||||
objectURL: item.path ? getLocalAttachmentUrl(item) : undefined,
|
||||
incrementalObjectUrl:
|
||||
isIncremental(item) && item.downloadPath
|
||||
? getLocalAttachmentUrl(item, {
|
||||
disposition: AttachmentDisposition.Download,
|
||||
})
|
||||
: undefined,
|
||||
path: item.path,
|
||||
contentType: item.contentType,
|
||||
loop,
|
||||
index,
|
||||
message: {
|
||||
attachments: message.get('attachments') || [],
|
||||
id: messageId,
|
||||
type: message.get('type'),
|
||||
conversationId: authorId,
|
||||
receivedAt,
|
||||
receivedAtMs: Number(message.get('received_at_ms')),
|
||||
sentAt,
|
||||
},
|
||||
attachment: item,
|
||||
thumbnailObjectUrl: item.thumbnail?.path
|
||||
? getLocalAttachmentUrl(item.thumbnail)
|
||||
: undefined,
|
||||
attachment: getPropsForAttachment(
|
||||
item,
|
||||
'attachment',
|
||||
message.attributes
|
||||
),
|
||||
size: item.size,
|
||||
totalDownloaded: item.totalDownloaded,
|
||||
}))
|
||||
.filter(item => item.objectURL || item.incrementalObjectUrl);
|
||||
.filter(item => item.attachment.url || item.attachment.incrementalUrl);
|
||||
|
||||
if (!media.length) {
|
||||
log.error(
|
||||
|
|
|
@ -6,22 +6,17 @@ import type { ThunkAction } from 'redux-thunk';
|
|||
import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
import { createLogger } from '../../logging/log';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { DataReader } from '../../sql/Client';
|
||||
import type { MediaItemDBType } from '../../sql/Interface';
|
||||
import {
|
||||
CONVERSATION_UNLOADED,
|
||||
MESSAGE_CHANGED,
|
||||
MESSAGE_DELETED,
|
||||
MESSAGE_EXPIRED,
|
||||
} from './conversations';
|
||||
import { VERSION_NEEDED_FOR_DISPLAY } from '../../types/Message2';
|
||||
import { isDownloading, hasFailed } from '../../types/Attachment';
|
||||
import { isNotNil } from '../../util/isNotNil';
|
||||
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
|
||||
import { getMessageIdForLogging } from '../../util/idForLogging';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
import type {
|
||||
ConversationUnloadedActionType,
|
||||
|
@ -29,33 +24,22 @@ import type {
|
|||
MessageDeletedActionType,
|
||||
MessageExpiredActionType,
|
||||
} from './conversations';
|
||||
import type { MIMEType } from '../../types/MIME';
|
||||
import type { MediaItemType } from '../../types/MediaItem';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import type { MessageAttributesType } from '../../model-types';
|
||||
import { MessageModel } from '../../models/messages';
|
||||
import { isTapToView } from '../selectors/message';
|
||||
import type { MessageAttributesType, MessageType } from '../../model-types';
|
||||
import { isTapToView, getPropsForAttachment } from '../selectors/message';
|
||||
|
||||
const log = createLogger('mediaGallery');
|
||||
|
||||
type MediaItemMessage = ReadonlyDeep<{
|
||||
attachments: Array<AttachmentType>;
|
||||
// Note that this reflects the sender, and not the parent conversation
|
||||
conversationId: string;
|
||||
type: MessageType;
|
||||
id: string;
|
||||
receivedAt: number;
|
||||
receivedAtMs: number;
|
||||
sentAt: number;
|
||||
}>;
|
||||
type MediaType = ReadonlyDeep<{
|
||||
path: string;
|
||||
objectURL: string;
|
||||
thumbnailObjectUrl?: string;
|
||||
contentType: MIMEType;
|
||||
index: number;
|
||||
attachment: AttachmentType;
|
||||
message: MediaItemMessage;
|
||||
}>;
|
||||
|
||||
export type MediaGalleryStateType = ReadonlyDeep<{
|
||||
conversationId: string | undefined;
|
||||
|
@ -63,7 +47,7 @@ export type MediaGalleryStateType = ReadonlyDeep<{
|
|||
haveOldestDocument: boolean;
|
||||
haveOldestMedia: boolean;
|
||||
loading: boolean;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
}>;
|
||||
|
||||
const FETCH_CHUNK_COUNT = 50;
|
||||
|
@ -78,14 +62,14 @@ type InitialLoadActionType = ReadonlyDeep<{
|
|||
payload: {
|
||||
conversationId: string;
|
||||
documents: ReadonlyArray<MediaItemType>;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
};
|
||||
}>;
|
||||
type LoadMoreMediaActionType = ReadonlyDeep<{
|
||||
type: typeof LOAD_MORE_MEDIA;
|
||||
payload: {
|
||||
conversationId: string;
|
||||
media: ReadonlyArray<MediaType>;
|
||||
media: ReadonlyArray<MediaItemType>;
|
||||
};
|
||||
}>;
|
||||
type LoadMoreDocumentsActionType = ReadonlyDeep<{
|
||||
|
@ -113,7 +97,9 @@ type MediaGalleryActionType = ReadonlyDeep<
|
|||
| SetLoadingActionType
|
||||
>;
|
||||
|
||||
function _sortMedia(media: ReadonlyArray<MediaType>): ReadonlyArray<MediaType> {
|
||||
function _sortMedia(
|
||||
media: ReadonlyArray<MediaItemType>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return orderBy(media, [
|
||||
'message.receivedAt',
|
||||
'message.sentAt',
|
||||
|
@ -130,13 +116,13 @@ function _getMediaItemMessage(
|
|||
message: ReadonlyDeep<MessageAttributesType>
|
||||
): MediaItemMessage {
|
||||
return {
|
||||
attachments: message.attachments || [],
|
||||
conversationId:
|
||||
window.ConversationController.lookupOrCreate({
|
||||
serviceId: message.sourceServiceId,
|
||||
e164: message.source,
|
||||
reason: 'conversation_view.showAllMedia',
|
||||
})?.id || message.conversationId,
|
||||
type: message.type,
|
||||
id: message.id,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: Number(message.received_at_ms),
|
||||
|
@ -145,99 +131,41 @@ function _getMediaItemMessage(
|
|||
}
|
||||
|
||||
function _cleanVisualAttachments(
|
||||
rawMedia: ReadonlyDeep<ReadonlyArray<MessageModel>>
|
||||
): ReadonlyArray<MediaType> {
|
||||
return rawMedia
|
||||
.flatMap(message => {
|
||||
let index = 0;
|
||||
|
||||
// Also checked via the DB query
|
||||
if (isTapToView(message.attributes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (message.get('attachments') || []).map(
|
||||
(attachment: AttachmentType): MediaType | undefined => {
|
||||
if (
|
||||
!attachment.path ||
|
||||
!attachment.thumbnail ||
|
||||
isDownloading(attachment) ||
|
||||
hasFailed(attachment)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { thumbnail } = attachment;
|
||||
const result = {
|
||||
path: attachment.path,
|
||||
objectURL: getLocalAttachmentUrl(attachment),
|
||||
thumbnailObjectUrl: thumbnail?.path
|
||||
? getLocalAttachmentUrl(thumbnail)
|
||||
: undefined,
|
||||
contentType: attachment.contentType,
|
||||
index,
|
||||
attachment,
|
||||
message: _getMediaItemMessage(message.attributes),
|
||||
};
|
||||
|
||||
index += 1;
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
})
|
||||
.filter(isNotNil);
|
||||
rawMedia: ReadonlyArray<MediaItemDBType>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return rawMedia.map(({ message, index, attachment }) => {
|
||||
return {
|
||||
index,
|
||||
attachment: getPropsForAttachment(attachment, 'attachment', message),
|
||||
message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function _cleanFileAttachments(
|
||||
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageModel>>
|
||||
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageAttributesType>>
|
||||
): ReadonlyArray<MediaItemType> {
|
||||
return rawDocuments
|
||||
.map(message => {
|
||||
if (isTapToView(message.attributes)) {
|
||||
if (isTapToView(message)) {
|
||||
return;
|
||||
}
|
||||
const attachments = message.get('attachments') || [];
|
||||
|
||||
const attachments = message.attachments || [];
|
||||
const attachment = attachments[0];
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
contentType: attachment.contentType,
|
||||
index: 0,
|
||||
attachment,
|
||||
message: {
|
||||
..._getMediaItemMessage(message.attributes),
|
||||
attachments: [attachment],
|
||||
},
|
||||
attachment: getPropsForAttachment(attachment, 'attachment', message),
|
||||
message: _getMediaItemMessage(message),
|
||||
};
|
||||
})
|
||||
.filter(isNotNil);
|
||||
}
|
||||
|
||||
async function _upgradeMessages(
|
||||
messages: ReadonlyArray<MessageModel>
|
||||
): Promise<void> {
|
||||
// We upgrade these messages so they are sure to have thumbnails
|
||||
await Promise.all(
|
||||
messages.map(async message => {
|
||||
try {
|
||||
await window.MessageCache.upgradeSchema(
|
||||
message,
|
||||
VERSION_NEEDED_FOR_DISPLAY
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'_upgradeMessages: Failed to upgrade message ' +
|
||||
`${getMessageIdForLogging(message.attributes)}: ${Errors.toLogFormat(error)}`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function initialLoad(
|
||||
conversationId: string
|
||||
): ThunkAction<
|
||||
|
@ -252,26 +180,17 @@ function initialLoad(
|
|||
payload: { loading: true },
|
||||
});
|
||||
|
||||
const rawMedia = (
|
||||
await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
requireVisualMediaAttachments: true,
|
||||
storyId: undefined,
|
||||
})
|
||||
).map(item => window.MessageCache.register(new MessageModel(item)));
|
||||
const rawDocuments = (
|
||||
await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
requireFileAttachments: true,
|
||||
storyId: undefined,
|
||||
})
|
||||
).map(item => window.MessageCache.register(new MessageModel(item)));
|
||||
|
||||
await _upgradeMessages(rawMedia);
|
||||
const rawMedia = await DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
});
|
||||
const rawDocuments = await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
requireFileAttachments: true,
|
||||
storyId: undefined,
|
||||
});
|
||||
|
||||
const media = _cleanVisualAttachments(rawMedia);
|
||||
const documents = _cleanFileAttachments(rawDocuments);
|
||||
|
@ -319,20 +238,13 @@ function loadMoreMedia(
|
|||
|
||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message;
|
||||
|
||||
const rawMedia = (
|
||||
await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
requireVisualMediaAttachments: true,
|
||||
sentAt,
|
||||
storyId: undefined,
|
||||
})
|
||||
).map(item => window.MessageCache.register(new MessageModel(item)));
|
||||
|
||||
await _upgradeMessages(rawMedia);
|
||||
const rawMedia = await DataReader.getOlderMedia({
|
||||
conversationId,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
});
|
||||
|
||||
const media = _cleanVisualAttachments(rawMedia);
|
||||
|
||||
|
@ -384,18 +296,16 @@ function loadMoreDocuments(
|
|||
|
||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
|
||||
|
||||
const rawDocuments = (
|
||||
await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
requireFileAttachments: true,
|
||||
sentAt,
|
||||
storyId: undefined,
|
||||
})
|
||||
).map(item => window.MessageCache.register(new MessageModel(item)));
|
||||
const rawDocuments = await DataReader.getOlderMessagesByConversation({
|
||||
conversationId,
|
||||
includeStoryReplies: false,
|
||||
limit: FETCH_CHUNK_COUNT,
|
||||
messageId,
|
||||
receivedAt,
|
||||
requireFileAttachments: true,
|
||||
sentAt,
|
||||
storyId: undefined,
|
||||
});
|
||||
|
||||
const documents = _cleanFileAttachments(rawDocuments);
|
||||
|
||||
|
@ -519,12 +429,23 @@ export function reducer(
|
|||
const oldestLoadedMedia = state.media[0];
|
||||
const oldestLoadedDocument = state.documents[0];
|
||||
|
||||
const newMedia = _cleanVisualAttachments([
|
||||
window.MessageCache.register(new MessageModel(message)),
|
||||
]);
|
||||
const newDocuments = _cleanFileAttachments([
|
||||
window.MessageCache.register(new MessageModel(message)),
|
||||
]);
|
||||
const newMedia = _cleanVisualAttachments(
|
||||
(message.attachments ?? []).map((attachment, index) => {
|
||||
return {
|
||||
index,
|
||||
attachment,
|
||||
message: {
|
||||
id: message.id,
|
||||
type: message.type,
|
||||
conversationId: message.conversationId,
|
||||
receivedAt: message.received_at,
|
||||
receivedAtMs: message.received_at_ms,
|
||||
sentAt: message.sent_at,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
const newDocuments = _cleanFileAttachments([message]);
|
||||
|
||||
let { documents, haveOldestDocument, haveOldestMedia, media } = state;
|
||||
|
||||
|
|
|
@ -66,7 +66,11 @@ import type {
|
|||
AttachmentForUIType,
|
||||
AttachmentType,
|
||||
} from '../../types/Attachment';
|
||||
import { isVoiceMessage, defaultBlurHash } from '../../types/Attachment';
|
||||
import {
|
||||
isVoiceMessage,
|
||||
isIncremental,
|
||||
defaultBlurHash,
|
||||
} from '../../types/Attachment';
|
||||
import type { AttachmentDownloadJobTypeType } from '../../types/AttachmentDownload';
|
||||
import { type DefaultConversationColorType } from '../../types/Colors';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
|
@ -79,7 +83,10 @@ import { isMoreRecentThan } from '../../util/timestamp';
|
|||
import * as iterables from '../../util/iterables';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { canEditMessage } from '../../util/canEditMessage';
|
||||
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
|
||||
import {
|
||||
getLocalAttachmentUrl,
|
||||
AttachmentDisposition,
|
||||
} from '../../util/getLocalAttachmentUrl';
|
||||
import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager';
|
||||
|
||||
import { getAccountSelector } from './accounts';
|
||||
|
@ -1853,6 +1860,12 @@ export function getPropsForAttachment(
|
|||
isVoiceMessage: isVoiceMessage(attachment),
|
||||
pending,
|
||||
url: path ? getLocalAttachmentUrl(attachment) : undefined,
|
||||
incrementalUrl:
|
||||
isIncremental(attachment) && attachment.downloadPath
|
||||
? getLocalAttachmentUrl(attachment, {
|
||||
disposition: AttachmentDisposition.Download,
|
||||
})
|
||||
: undefined,
|
||||
thumbnailFromBackup: thumbnailFromBackup?.path
|
||||
? {
|
||||
...thumbnailFromBackup,
|
||||
|
|
|
@ -4,6 +4,7 @@ import React, { memo } from 'react';
|
|||
import { useSelector } from 'react-redux';
|
||||
import { MediaGallery } from '../../components/conversation/media-gallery/MediaGallery';
|
||||
import { getMediaGalleryState } from '../selectors/mediaGallery';
|
||||
import { getIntl, getTheme } from '../selectors/user';
|
||||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { useLightboxActions } from '../ducks/lightbox';
|
||||
import { useMediaGalleryActions } from '../ducks/mediaGallery';
|
||||
|
@ -19,15 +20,22 @@ export const SmartAllMedia = memo(function SmartAllMedia({
|
|||
useSelector(getMediaGalleryState);
|
||||
const { initialLoad, loadMoreMedia, loadMoreDocuments } =
|
||||
useMediaGalleryActions();
|
||||
const { saveAttachment } = useConversationsActions();
|
||||
const {
|
||||
saveAttachment,
|
||||
kickOffAttachmentDownload,
|
||||
cancelAttachmentDownload,
|
||||
} = useConversationsActions();
|
||||
const { showLightbox } = useLightboxActions();
|
||||
const i18n = useSelector(getIntl);
|
||||
const theme = useSelector(getTheme);
|
||||
|
||||
return (
|
||||
<MediaGallery
|
||||
conversationId={conversationId}
|
||||
haveOldestDocument={haveOldestDocument}
|
||||
haveOldestMedia={haveOldestMedia}
|
||||
i18n={window.i18n}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
initialLoad={initialLoad}
|
||||
loading={loading}
|
||||
loadMoreMedia={loadMoreMedia}
|
||||
|
@ -35,6 +43,8 @@ export const SmartAllMedia = memo(function SmartAllMedia({
|
|||
media={media}
|
||||
documents={documents}
|
||||
showLightbox={showLightbox}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
cancelAttachmentDownload={cancelAttachmentDownload}
|
||||
saveAttachment={saveAttachment}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { sortBy } from 'lodash';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useCallback, useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails';
|
||||
import {
|
||||
|
@ -40,8 +40,9 @@ import { useConversationsActions } from '../ducks/conversations';
|
|||
import { useCallingActions } from '../ducks/calling';
|
||||
import { useSearchActions } from '../ducks/search';
|
||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||
import { useLightboxActions } from '../ducks/lightbox';
|
||||
import { isSignalConversation } from '../../util/isSignalConversation';
|
||||
import { drop } from '../../util/drop';
|
||||
import { DataReader } from '../../sql/Client';
|
||||
|
||||
export type SmartConversationDetailsProps = {
|
||||
conversationId: string;
|
||||
|
@ -109,7 +110,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
|
|||
deleteAvatarFromDisk,
|
||||
getProfilesForConversation,
|
||||
leaveGroup,
|
||||
loadRecentMediaItems,
|
||||
pushPanelForConversation,
|
||||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
|
@ -132,7 +132,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
|
|||
toggleEditNicknameAndNoteModal,
|
||||
toggleSafetyNumberModal,
|
||||
} = useGlobalModalActions();
|
||||
const { showLightbox } = useLightboxActions();
|
||||
|
||||
const conversation = conversationSelector(conversationId);
|
||||
assertDev(
|
||||
|
@ -177,6 +176,26 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
|
|||
toggleEditNicknameAndNoteModal({ conversationId });
|
||||
}, [conversationId, toggleEditNicknameAndNoteModal]);
|
||||
|
||||
const [hasMedia, setHasMedia] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isCanceled = false;
|
||||
|
||||
drop(
|
||||
(async () => {
|
||||
const result = await DataReader.hasMedia(conversationId);
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
setHasMedia(result);
|
||||
})()
|
||||
);
|
||||
|
||||
return () => {
|
||||
isCanceled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
return (
|
||||
<ConversationDetails
|
||||
acceptConversation={acceptConversation}
|
||||
|
@ -199,7 +218,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
|
|||
isGroup={isGroup}
|
||||
isSignalConversation={isSignalConversation(conversation)}
|
||||
leaveGroup={leaveGroup}
|
||||
loadRecentMediaItems={loadRecentMediaItems}
|
||||
hasMedia={hasMedia}
|
||||
maxGroupSize={maxGroupSize}
|
||||
maxRecommendedGroupSize={maxRecommendedGroupSize}
|
||||
memberships={memberships}
|
||||
|
@ -221,7 +240,6 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
|
|||
setMuteExpiration={setMuteExpiration}
|
||||
showContactModal={showContactModal}
|
||||
showConversation={showConversation}
|
||||
showLightbox={showLightbox}
|
||||
startAvatarDownload={() => startAvatarDownload(conversationId)}
|
||||
theme={theme}
|
||||
toggleAboutContactModal={toggleAboutContactModal}
|
||||
|
|
|
@ -29,7 +29,6 @@ import { SmartStickerManager } from './StickerManager';
|
|||
import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import {
|
||||
getIsPanelAnimating,
|
||||
getPanelInformation,
|
||||
getWasPanelAnimated,
|
||||
} from '../selectors/conversations';
|
||||
|
@ -111,7 +110,6 @@ export const ConversationPanel = memo(function ConversationPanel({
|
|||
const i18n = useSelector(getIntl);
|
||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
||||
|
||||
const isAnimating = useSelector(getIsPanelAnimating);
|
||||
const wasAnimated = useSelector(getWasPanelAnimated);
|
||||
|
||||
const [lastPanelDoneAnimating, setLastPanelDoneAnimating] =
|
||||
|
@ -217,16 +215,22 @@ export const ConversationPanel = memo(function ConversationPanel({
|
|||
<>
|
||||
{activePanel && (
|
||||
<PanelContainer
|
||||
key={getPanelKey(activePanel)}
|
||||
conversationId={conversationId}
|
||||
isActive
|
||||
panel={activePanel}
|
||||
/>
|
||||
)}
|
||||
{lastPanelDoneAnimating !== prevPanel && (
|
||||
<div className="ConversationPanel__overlay" ref={overlayRef} />
|
||||
<div
|
||||
key="overlay"
|
||||
className="ConversationPanel__overlay"
|
||||
ref={overlayRef}
|
||||
/>
|
||||
)}
|
||||
{prevPanel && lastPanelDoneAnimating !== prevPanel && (
|
||||
<PanelContainer
|
||||
key={getPanelKey(prevPanel)}
|
||||
conversationId={conversationId}
|
||||
panel={prevPanel}
|
||||
ref={animateRef}
|
||||
|
@ -239,11 +243,20 @@ export const ConversationPanel = memo(function ConversationPanel({
|
|||
if (direction === 'push' && activePanel) {
|
||||
return (
|
||||
<>
|
||||
{isAnimating && prevPanel && (
|
||||
<PanelContainer conversationId={conversationId} panel={prevPanel} />
|
||||
{lastPanelDoneAnimating !== prevPanel && prevPanel && (
|
||||
<PanelContainer
|
||||
conversationId={conversationId}
|
||||
panel={prevPanel}
|
||||
key={getPanelKey(prevPanel)}
|
||||
/>
|
||||
)}
|
||||
<div className="ConversationPanel__overlay" ref={overlayRef} />
|
||||
<div
|
||||
key="overlay"
|
||||
className="ConversationPanel__overlay"
|
||||
ref={overlayRef}
|
||||
/>
|
||||
<PanelContainer
|
||||
key={getPanelKey(activePanel)}
|
||||
conversationId={conversationId}
|
||||
isActive
|
||||
panel={activePanel}
|
||||
|
@ -374,3 +387,25 @@ function PanelElement({
|
|||
log.warn(toLogFormat(missingCaseError(panel)));
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPanelKey(panel: PanelRenderType): string {
|
||||
switch (panel.type) {
|
||||
case PanelType.AllMedia:
|
||||
case PanelType.ChatColorEditor:
|
||||
case PanelType.ConversationDetails:
|
||||
case PanelType.GroupInvites:
|
||||
case PanelType.GroupLinkManagement:
|
||||
case PanelType.GroupPermissions:
|
||||
case PanelType.GroupV1Members:
|
||||
case PanelType.NotificationSettings:
|
||||
case PanelType.StickerManager:
|
||||
return panel.type;
|
||||
case PanelType.MessageDetails:
|
||||
return `${panel.type}:${panel.args.message.id}`;
|
||||
case PanelType.ContactDetails:
|
||||
return `${panel.type}:${panel.args.messageId}`;
|
||||
default:
|
||||
log.warn(toLogFormat(missingCaseError(panel)));
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,20 +20,19 @@ const testDate = (
|
|||
|
||||
const toMediaItem = (id: string, date: Date): MediaItemType => {
|
||||
return {
|
||||
objectURL: id,
|
||||
index: 0,
|
||||
message: {
|
||||
type: 'incoming',
|
||||
conversationId: '1234',
|
||||
id: 'id',
|
||||
receivedAt: date.getTime(),
|
||||
receivedAtMs: date.getTime(),
|
||||
attachments: [],
|
||||
sentAt: date.getTime(),
|
||||
},
|
||||
attachment: fakeAttachment({
|
||||
fileName: 'fileName',
|
||||
contentType: IMAGE_JPEG,
|
||||
url: 'url',
|
||||
url: id,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -62,35 +61,35 @@ describe('groupMediaItemsByDate', () => {
|
|||
|
||||
assert.strictEqual(actual[0].type, 'today');
|
||||
assert.strictEqual(actual[0].mediaItems.length, 2, 'today');
|
||||
assert.strictEqual(actual[0].mediaItems[0].objectURL, 'today-1');
|
||||
assert.strictEqual(actual[0].mediaItems[1].objectURL, 'today-2');
|
||||
assert.strictEqual(actual[0].mediaItems[0].attachment.url, 'today-1');
|
||||
assert.strictEqual(actual[0].mediaItems[1].attachment.url, 'today-2');
|
||||
|
||||
assert.strictEqual(actual[1].type, 'yesterday');
|
||||
assert.strictEqual(actual[1].mediaItems.length, 2, 'yesterday');
|
||||
assert.strictEqual(actual[1].mediaItems[0].objectURL, 'yesterday-1');
|
||||
assert.strictEqual(actual[1].mediaItems[1].objectURL, 'yesterday-2');
|
||||
assert.strictEqual(actual[1].mediaItems[0].attachment.url, 'yesterday-1');
|
||||
assert.strictEqual(actual[1].mediaItems[1].attachment.url, 'yesterday-2');
|
||||
|
||||
assert.strictEqual(actual[2].type, 'thisWeek');
|
||||
assert.strictEqual(actual[2].mediaItems.length, 4, 'thisWeek');
|
||||
assert.strictEqual(actual[2].mediaItems[0].objectURL, 'thisWeek-1');
|
||||
assert.strictEqual(actual[2].mediaItems[1].objectURL, 'thisWeek-2');
|
||||
assert.strictEqual(actual[2].mediaItems[2].objectURL, 'thisWeek-3');
|
||||
assert.strictEqual(actual[2].mediaItems[3].objectURL, 'thisWeek-4');
|
||||
assert.strictEqual(actual[2].mediaItems[0].attachment.url, 'thisWeek-1');
|
||||
assert.strictEqual(actual[2].mediaItems[1].attachment.url, 'thisWeek-2');
|
||||
assert.strictEqual(actual[2].mediaItems[2].attachment.url, 'thisWeek-3');
|
||||
assert.strictEqual(actual[2].mediaItems[3].attachment.url, 'thisWeek-4');
|
||||
|
||||
assert.strictEqual(actual[3].type, 'thisMonth');
|
||||
assert.strictEqual(actual[3].mediaItems.length, 2, 'thisMonth');
|
||||
assert.strictEqual(actual[3].mediaItems[0].objectURL, 'thisMonth-1');
|
||||
assert.strictEqual(actual[3].mediaItems[1].objectURL, 'thisMonth-2');
|
||||
assert.strictEqual(actual[3].mediaItems[0].attachment.url, 'thisMonth-1');
|
||||
assert.strictEqual(actual[3].mediaItems[1].attachment.url, 'thisMonth-2');
|
||||
|
||||
assert.strictEqual(actual[4].type, 'yearMonth');
|
||||
assert.strictEqual(actual[4].mediaItems.length, 2, 'mar2024');
|
||||
assert.strictEqual(actual[4].mediaItems[0].objectURL, 'mar2024-1');
|
||||
assert.strictEqual(actual[4].mediaItems[1].objectURL, 'mar2024-2');
|
||||
assert.strictEqual(actual[4].mediaItems[0].attachment.url, 'mar2024-1');
|
||||
assert.strictEqual(actual[4].mediaItems[1].attachment.url, 'mar2024-2');
|
||||
|
||||
assert.strictEqual(actual[5].type, 'yearMonth');
|
||||
assert.strictEqual(actual[5].mediaItems.length, 2, 'feb2011');
|
||||
assert.strictEqual(actual[5].mediaItems[0].objectURL, 'feb2011-1');
|
||||
assert.strictEqual(actual[5].mediaItems[1].objectURL, 'feb2011-2');
|
||||
assert.strictEqual(actual[5].mediaItems[0].attachment.url, 'feb2011-1');
|
||||
assert.strictEqual(actual[5].mediaItems[1].attachment.url, 'feb2011-2');
|
||||
|
||||
assert.strictEqual(actual.length, 6, 'total sections');
|
||||
});
|
||||
|
|
|
@ -88,6 +88,7 @@ export type EphemeralAttachmentFields = {
|
|||
isVoiceMessage?: boolean;
|
||||
/** For messages not already on disk, this will be a data url */
|
||||
url?: string;
|
||||
incrementalUrl?: string;
|
||||
screenshotData?: Uint8Array;
|
||||
/** @deprecated Legacy field */
|
||||
screenshotPath?: string;
|
||||
|
|
|
@ -1,27 +1,20 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyMessageAttributesType } from '../model-types.d';
|
||||
import type { AttachmentType } from './Attachment';
|
||||
import type { MIMEType } from './MIME';
|
||||
import type { AttachmentForUIType } from './Attachment';
|
||||
import type { MessageAttributesType } from '../model-types.d';
|
||||
|
||||
export type MediaItemMessageType = Pick<
|
||||
ReadonlyMessageAttributesType,
|
||||
'attachments' | 'conversationId' | 'id'
|
||||
> & {
|
||||
export type MediaItemMessageType = Readonly<{
|
||||
id: string;
|
||||
type: MessageAttributesType['type'];
|
||||
conversationId: string;
|
||||
receivedAt: number;
|
||||
receivedAtMs?: number;
|
||||
sentAt: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type MediaItemType = {
|
||||
attachment: AttachmentType;
|
||||
contentType?: MIMEType;
|
||||
attachment: AttachmentForUIType;
|
||||
index: number;
|
||||
loop?: boolean;
|
||||
message: MediaItemMessageType;
|
||||
objectURL?: string;
|
||||
incrementalObjectUrl?: string;
|
||||
thumbnailObjectUrl?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
|
25
ts/util/randomBlurHash.ts
Normal file
25
ts/util/randomBlurHash.ts
Normal 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
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue