Show attachment download progress, new stop button to cancel

Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
Scott Nonnenberg 2024-12-10 08:54:18 +10:00 committed by GitHub
parent 025841e5bb
commit 2741fbb5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 2192 additions and 562 deletions

View file

@ -23,10 +23,8 @@ export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
platform: string;
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
showLightbox: (options: {
attachment: AttachmentType;
messageId: string;
@ -73,6 +71,7 @@ const MESSAGE_DEFAULT_PROPS = {
};
export function EditHistoryMessagesModal({
cancelAttachmentDownload,
closeEditHistoryModal,
getPreferredBadge,
editHistoryMessages,
@ -127,12 +126,8 @@ export function EditHistoryMessagesModal({
isEditedMessage
isSpoilerExpanded={revealedSpoilersById[currentMessageId] || {}}
key={currentMessage.timestamp}
kickOffAttachmentDownload={({ attachment }) =>
kickOffAttachmentDownload({
attachment,
messageId: currentMessage.id,
})
}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
messageExpanded={(messageId, displayLimit) => {
const update = {
...displayLimitById,
@ -195,12 +190,8 @@ export function EditHistoryMessagesModal({
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}}
kickOffAttachmentDownload={({ attachment }) =>
kickOffAttachmentDownload({
attachment,
messageId: messageAttributes.id,
})
}
kickOffAttachmentDownload={kickOffAttachmentDownload}
cancelAttachmentDownload={cancelAttachmentDownload}
messageExpanded={(messageId, displayLimit) => {
const update = {
...displayLimitById,

View file

@ -55,6 +55,7 @@ const MESSAGE_DEFAULT_PROPS = {
onToggleSelect: shouldNeverBeCalled,
onReplyToMessage: shouldNeverBeCalled,
kickOffAttachmentDownload: shouldNeverBeCalled,
cancelAttachmentDownload: shouldNeverBeCalled,
markAttachmentAsCorrupted: shouldNeverBeCalled,
messageExpanded: shouldNeverBeCalled,
openGiftBadge: shouldNeverBeCalled,

View file

@ -0,0 +1,91 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { type PropsType, AttachmentDetailPill } from './AttachmentDetailPill';
import { type ComponentMeta } from '../../storybook/types';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Conversation/AttachmentDetailPill',
component: AttachmentDetailPill,
argTypes: {
isGif: { control: { type: 'boolean' } },
},
args: {
i18n,
attachments: [],
isGif: false,
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
},
} satisfies ComponentMeta<PropsType>;
export function NoneDefaultsBlank(args: PropsType): JSX.Element {
return <AttachmentDetailPill {...args} />;
}
export function OneDownloadedBlank(args: PropsType): JSX.Element {
return <AttachmentDetailPill {...args} attachments={[fakeAttachment()]} />;
}
export function OneNotPendingNotDownloaded(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
path: undefined,
}),
]}
/>
);
}
export function OnePendingNotDownloading(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
pending: true,
path: undefined,
}),
]}
/>
);
}
export function OneDownloading(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
pending: true,
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}
export function OneNotPendingSomeDownloaded(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}

View file

@ -0,0 +1,57 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { formatFileSize } from '../../util/formatFileSize';
import type { AttachmentForUIType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
export type PropsType = {
attachments: ReadonlyArray<AttachmentForUIType>;
i18n: LocalizerType;
isGif?: boolean;
startDownload: () => void;
cancelDownload: () => void;
};
export function AttachmentDetailPill({
attachments,
isGif,
}: PropsType): JSX.Element | null {
const areAllDownloaded = attachments.every(attachment => attachment.path);
const totalSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => {
return total + (attachment.size ?? 0);
},
0
);
if (areAllDownloaded || totalSize === 0) {
return null;
}
const totalDownloadedSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => {
return (
total +
(attachment.path ? attachment.size : (attachment.totalDownloaded ?? 0))
);
},
0
);
const areAnyPending = attachments.some(attachment => attachment.pending);
return (
<div className="AttachmentDetailPill">
<div className="AttachmentDetailPill__text-wrapper">
{totalDownloadedSize > 0 && areAnyPending
? `${formatFileSize(totalDownloadedSize, 2)} / `
: undefined}
{formatFileSize(totalSize, 2)}
{isGif ? ' · GIF' : undefined}
</div>
</div>
);
}

View file

@ -91,7 +91,6 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
isVideo ||
attachment.pending
) {
const isDownloaded = !attachment.pending;
const imageUrl =
url || (isVideo ? BLANK_VIDEO_THUMBNAIL : undefined);
@ -108,7 +107,6 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
className="module-staged-attachment"
i18n={i18n}
attachment={attachment}
isDownloaded={isDownloaded}
curveBottomLeft={CurveType.Tiny}
curveBottomRight={CurveType.Tiny}
curveTopLeft={CurveType.Tiny}
@ -118,7 +116,7 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
width={IMAGE_WIDTH}
url={imageUrl}
closeButton
onClick={clickAttachment}
showVisualAttachment={clickAttachment}
onClickClose={closeAttachment}
onError={closeAttachment}
/>

View file

@ -1,14 +1,13 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState, useEffect } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { Spinner } from '../Spinner';
import type { AttachmentType } from '../../types/Attachment';
import type { AttachmentForUIType } from '../../types/Attachment';
import {
hasNotResolved,
getImageDimensions,
@ -17,21 +16,26 @@ import {
import * as Errors from '../../types/errors';
import * as log from '../../logging/log';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { AttachmentDetailPill } from './AttachmentDetailPill';
import { getSpinner } from './Image';
const MAX_GIF_REPEAT = 4;
const MAX_GIF_TIME = 8;
export type Props = {
readonly attachment: AttachmentType;
readonly attachment: AttachmentForUIType;
readonly size?: number;
readonly tabIndex: number;
// test-only, to force reduced motion experience
readonly _forceTapToPlay?: boolean;
readonly i18n: LocalizerType;
readonly theme?: ThemeType;
onError(): void;
showVisualAttachment(): void;
kickOffAttachmentDownload(): void;
startDownload(): void;
cancelDownload(): void;
};
type MediaEvent = React.SyntheticEvent<HTMLVideoElement, Event>;
@ -41,16 +45,18 @@ export function GIF(props: Props): JSX.Element {
attachment,
size,
tabIndex,
_forceTapToPlay,
i18n,
theme,
onError,
showVisualAttachment,
kickOffAttachmentDownload,
startDownload,
cancelDownload,
} = props;
const tapToPlay = useReducedMotion();
const tapToPlay = useReducedMotion() || _forceTapToPlay;
const videoRef = useRef<HTMLVideoElement | null>(null);
const { height, width } = getImageDimensions(attachment, size);
@ -142,7 +148,7 @@ export function GIF(props: Props): JSX.Element {
event.stopPropagation();
if (!attachment.url) {
kickOffAttachmentDownload();
startDownload();
} else if (tapToPlay) {
setPlayTime(0);
setCurrentTime(0);
@ -158,21 +164,18 @@ export function GIF(props: Props): JSX.Element {
event.preventDefault();
event.stopPropagation();
kickOffAttachmentDownload();
if (!attachment.url) {
startDownload();
} else if (tapToPlay) {
setPlayTime(0);
setCurrentTime(0);
setRepeatCount(0);
}
};
const isPending = Boolean(attachment.pending);
const isNotResolved = hasNotResolved(attachment) && !isPending;
let fileSize: JSX.Element | undefined;
if (isNotResolved && attachment.fileSize) {
fileSize = (
<div className="module-image--gif__filesize">
{attachment.fileSize} · GIF
</div>
);
}
let gif: JSX.Element | undefined;
if (isNotResolved || isPending) {
gif = (
@ -208,6 +211,35 @@ export function GIF(props: Props): JSX.Element {
);
}
const cancelDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (cancelDownload) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const cancelDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const spinner = getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
let overlay: JSX.Element | undefined;
if ((tapToPlay && !isPlaying) || isNotResolved) {
const className = classNames([
@ -232,26 +264,22 @@ export function GIF(props: Props): JSX.Element {
);
}
let spinner: JSX.Element | undefined;
if (isPending) {
spinner = (
<div className="module-image__download-pending--spinner-container">
<div
className="module-image__download-pending--spinner"
title={i18n('icu:loading')}
>
<Spinner moduleClassName="module-image-spinner" svgSize="small" />
</div>
</div>
);
}
const detailPill = (
<AttachmentDetailPill
attachments={[attachment]}
cancelDownload={cancelDownload}
i18n={i18n}
isGif
startDownload={startDownload}
/>
);
return (
<div className="module-image module-image--gif">
{gif}
{overlay}
{spinner}
{fileSize}
{overlay}
{detailPill}
</div>
);
}

View file

@ -38,11 +38,13 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
curveTopLeft: overrideProps.curveTopLeft || CurveType.None,
curveTopRight: overrideProps.curveTopRight || CurveType.None,
darkOverlay: overrideProps.darkOverlay || false,
height: overrideProps.height || 100,
height: overrideProps.height || 200,
i18n,
noBackground: overrideProps.noBackground || false,
noBorder: overrideProps.noBorder || false,
onClick: action('onClick'),
showVisualAttachment: action('showVisualAttachment'),
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
onClickClose: action('onClickClose'),
onError: action('onError'),
overlayText: overrideProps.overlayText || '',
@ -50,7 +52,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
tabIndex: overrideProps.tabIndex || 0,
theme: overrideProps.theme || ('light' as ThemeType),
url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl,
width: overrideProps.width || 100,
width: overrideProps.width || 300,
});
export function UrlWithHeightWidth(): JSX.Element {
@ -107,37 +109,68 @@ export function NoBorderOrBackground(): JSX.Element {
);
}
export function Pending(): JSX.Element {
export function NotDownloadedNotIncrementalNotPending(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
url: pngUrl,
pending: true,
path: undefined,
size: 5300000,
}),
url: undefined,
blurHash: 'thisisafakeblurhashthatwasmadeup',
});
return <Image {...props} />;
}
export function PendingWBlurhash(): JSX.Element {
export function PendingWDownloadQueuedNotIncremental(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
url: pngUrl,
path: undefined,
pending: true,
size: 5300000,
}),
url: undefined,
blurHash: 'thisisafakeblurhashthatwasmadeup',
});
return (
<Image
{...props}
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]"
width={300}
height={400}
/>
);
return <Image {...props} />;
}
export function PendingWDownloadProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 5300000,
totalDownloaded: 1230000,
}),
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function NotPendingWDownloadProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
size: 5300000,
totalDownloaded: 1230000,
}),
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function CurvedCorners(): JSX.Element {
@ -188,11 +221,14 @@ export function FullOverlayWithText(): JSX.Element {
}
export function Blurhash(): JSX.Element {
const defaultProps = createProps();
const props = {
...defaultProps,
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
}),
blurHash: 'thisisafakeblurhashthatwasmadeup',
};
url: undefined,
});
return <Image {...props} />;
}
@ -213,12 +249,10 @@ export function UndefinedBlurHash(): JSX.Element {
}
export function MissingImage(): JSX.Element {
const defaultProps = createProps();
const props = {
...defaultProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
attachment: undefined as any,
};
const props = createProps({
attachment: undefined,
url: 'random',
});
return <Image {...props} />;
}

View file

@ -2,17 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import {
isDownloaded as isDownloadedFunction,
defaultBlurHash,
import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { defaultBlurHash, isReadyToView } from '../../types/Attachment';
import { ProgressCircle } from '../ProgressCircle';
export enum CurveType {
None = 0,
@ -23,10 +24,9 @@ export enum CurveType {
export type Props = {
alt: string;
attachment: AttachmentType;
attachment: AttachmentForUIType;
url?: string;
isDownloaded?: boolean;
className?: string;
height?: number;
width?: number;
@ -51,7 +51,9 @@ export type Props = {
i18n: LocalizerType;
theme?: ThemeType;
onClick?: (attachment: AttachmentType) => void;
showVisualAttachment?: (attachment: AttachmentType) => void;
cancelDownload?: () => void;
startDownload?: () => void;
onClickClose?: (attachment: AttachmentType) => void;
onError?: () => void;
};
@ -68,12 +70,13 @@ export function Image({
curveTopLeft,
curveTopRight,
darkOverlay,
isDownloaded,
height = 0,
i18n,
noBackground,
noBorder,
onClick,
showVisualAttachment,
startDownload,
cancelDownload,
onClickClose,
onError,
overlayText,
@ -85,11 +88,6 @@ export function Image({
cropWidth = 0,
cropHeight = 0,
}: Props): JSX.Element {
const { caption, pending } = attachment || { caption: null, pending: true };
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
const curveStyles: CSSProperties = {
@ -99,48 +97,112 @@ export function Image({
borderEndEndRadius: curveBottomRight || CurveType.None,
};
const canClick = useMemo(() => {
return onClick != null && !pending;
}, [pending, onClick]);
const handleClick = useCallback(
const showVisualAttachmentClick = useCallback(
(event: React.MouseEvent) => {
if (!canClick) {
if (showVisualAttachment) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
showVisualAttachment(attachment);
}
},
[attachment, canClick, onClick]
[attachment, showVisualAttachment]
);
const handleKeyDown = useCallback(
const showVisualAttachmentKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!canClick) {
if (
showVisualAttachment &&
(event.key === 'Enter' || event.key === 'Space')
) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
showVisualAttachment(attachment);
}
},
[attachment, canClick, onClick]
[attachment, showVisualAttachment]
);
const cancelDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (cancelDownload) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const cancelDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const startDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (startDownload) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
const startDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (startDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
/* eslint-disable no-nested-ternary */
const imageOrBlurHash = url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
);
const startDownloadButton =
startDownload && !attachment.path && !attachment.pending ? (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:startDownload')}
onClick={startDownloadClick}
onKeyDown={startDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__download-icon" />
</button>
) : undefined;
const spinner = !cancelDownload
? undefined
: getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
return (
<div
className={classNames(
@ -155,70 +217,11 @@ export function Image({
...curveStyles,
}}
>
{pending ? (
url || blurHash ? (
<div className="module-image__download-pending">
{url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : blurHash ? (
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : undefined}
<div className="module-image__download-pending--spinner-container">
<div
className="module-image__download-pending--spinner"
title={i18n('icu:loading')}
>
<Spinner
moduleClassName="module-image-spinner"
svgSize="small"
/>
</div>
</div>
</div>
) : (
<div
className="module-image__loading-placeholder"
style={{
height: `${height}px`,
width: `${width}px`,
lineHeight: `${height}px`,
textAlign: 'center',
}}
title={i18n('icu:loading')}
>
<Spinner svgSize="normal" />
</div>
)
) : url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : resolvedBlurHash ? (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
{imageOrBlurHash}
{startDownloadButton}
{spinner}
{attachment.caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
@ -234,9 +237,9 @@ export function Image({
}}
/>
) : null}
{!pending && !imgNotDownloaded && playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
{attachment.path && playIconOverlay ? (
<div className="module-image__overlay-circle">
<div className="module-image__play-icon" />
</div>
) : null}
{overlayText ? (
@ -247,22 +250,27 @@ export function Image({
{overlayText}
</div>
) : null}
{canClick ? (
{darkOverlay || !noBorder ? (
<div
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--dark': darkOverlay,
})}
style={curveStyles}
/>
) : null}
{showVisualAttachment && isReadyToView(attachment) ? (
<button
type="button"
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--with-click-handler': canClick,
'module-image__border-overlay--dark': darkOverlay,
'module-image--not-downloaded': imgNotDownloaded,
'module-image__border-overlay--with-click-handler': true,
})}
aria-label={i18n('icu:imageOpenAlt')}
style={curveStyles}
onClick={handleClick}
onKeyDown={handleKeyDown}
onClick={showVisualAttachmentClick}
onKeyDown={showVisualAttachmentKeyDown}
tabIndex={tabIndex}
>
{imgNotDownloaded ? <span /> : null}
</button>
/>
) : null}
{closeButton ? (
<button
@ -282,5 +290,71 @@ export function Image({
) : null}
</div>
);
/* eslint-enable no-nested-ternary */
}
export function getSpinner({
attachment,
cancelDownloadClick,
cancelDownloadKeyDown,
i18n,
tabIndex,
}: {
attachment: AttachmentForUIType;
cancelDownloadClick: (event: React.MouseEvent) => void;
cancelDownloadKeyDown: (
event: React.KeyboardEvent<HTMLButtonElement>
) => void;
i18n: LocalizerType;
tabIndex: number | undefined;
}): JSX.Element | undefined {
const downloadFraction =
attachment.pending && attachment.size && attachment.totalDownloaded
? attachment.totalDownloaded / attachment.size
: undefined;
if (downloadFraction) {
return (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:cancelDownload')}
onClick={cancelDownloadClick}
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__stop-icon" />
<div className="module-image__progress-circle-wrapper">
<ProgressCircle
fractionComplete={downloadFraction}
width={44}
strokeWidth={2}
/>
</div>
</button>
);
}
if (!attachment.pending) {
return undefined;
}
return (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:cancelDownload')}
onClick={cancelDownloadClick}
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__spinner-container">
<Spinner
moduleClassName="module-image-spinner"
svgSize="normal"
size="44px"
/>
<div className="module-image__stop-icon" />
</div>
</button>
);
}

View file

@ -44,7 +44,9 @@ export default {
direction: 'incoming',
i18n,
isSticker: false,
onClick: action('onClick'),
showVisualAttachment: action('showVisualAttachment'),
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
onError: action('onError'),
stickerSize: 0,
tabIndex: 0,
@ -57,13 +59,111 @@ export function OneImage(args: Props): JSX.Element {
return <ImageGrid {...args} />;
}
export function OneVideo(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
url: pngUrl,
width: 800,
screenshot: {
path: 'something',
url: pngUrl,
contentType: IMAGE_PNG,
height: 1200,
width: 800,
},
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoNotDownloadedNotPending(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoPendingWDownloadQueued(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
url: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoDownloadProgressNotPending(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function TwoImages(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: IMAGE_PNG,
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
url: pngUrl,
@ -81,6 +181,62 @@ export function TwoImages(args: Props): JSX.Element {
);
}
export function TwoImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function TwoImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function ThreeImages(args: Props): JSX.Element {
return (
<ImageGrid
@ -112,6 +268,74 @@ export function ThreeImages(args: Props): JSX.Element {
);
}
export function ThreeImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function ThreeImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function FourImages(args: Props): JSX.Element {
return (
<ImageGrid
@ -150,6 +374,89 @@ export function FourImages(args: Props): JSX.Element {
);
}
export function FourImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function FourImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function FiveImages(args: Props): JSX.Element {
return (
<ImageGrid
@ -195,6 +502,104 @@ export function FiveImages(args: Props): JSX.Element {
);
}
export function FiveImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function FiveImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export const _6Images = (args: Props): JSX.Element => {
return (
<ImageGrid
@ -254,6 +659,63 @@ export const _6Images = (args: Props): JSX.Element => {
);
};
export function _6ImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function MixedContentTypes(args: Props): JSX.Element {
return (
<ImageGrid
@ -295,6 +757,80 @@ export function MixedContentTypes(args: Props): JSX.Element {
);
}
export function EightImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function Sticker(args: Props): JSX.Element {
return (
<ImageGrid

View file

@ -20,6 +20,7 @@ import {
import { Image, CurveType } from './Image';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { AttachmentDetailPill } from './AttachmentDetailPill';
export type DirectionType = 'incoming' | 'outgoing';
@ -39,7 +40,9 @@ export type Props = {
theme?: ThemeType;
onError: () => void;
onClick?: (attachment: AttachmentType) => void;
showVisualAttachment: (attachment: AttachmentType) => void;
cancelDownload: () => void;
startDownload: () => void;
};
const GAP = 1;
@ -108,7 +111,9 @@ export function ImageGrid({
isSticker,
stickerSize,
onError,
onClick,
showVisualAttachment,
cancelDownload,
startDownload,
shouldCollapseAbove,
shouldCollapseBelow,
tabIndex,
@ -127,10 +132,46 @@ export function ImageGrid({
const withBottomOverlay = Boolean(bottomOverlay && !withContentBelow);
const startDownloadClick = React.useCallback(
(event: React.MouseEvent) => {
if (startDownload) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
const startDownloadKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (startDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
if (!attachments || !attachments.length) {
return null;
}
const detailPill = (
<AttachmentDetailPill
attachments={attachments}
i18n={i18n}
startDownload={startDownload}
cancelDownload={cancelDownload}
/>
);
const downloadPill = renderDownloadPill({
attachments,
i18n,
startDownloadClick,
startDownloadKeyDown,
});
if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
const { height, width } = getImageDimensions(
attachments[0],
@ -165,9 +206,12 @@ export function ImageGrid({
getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url
}
tabIndex={tabIndex}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={startDownload}
onError={onError}
/>
{detailPill}
</div>
);
}
@ -190,7 +234,9 @@ export function ImageGrid({
width={150}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -207,9 +253,13 @@ export function ImageGrid({
width={150}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
{detailPill}
{downloadPill}
</div>
);
}
@ -232,7 +282,9 @@ export function ImageGrid({
width={200}
cropWidth={GAP}
url={getUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<div className="module-image-grid__column">
@ -248,7 +300,9 @@ export function ImageGrid({
attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -264,10 +318,14 @@ export function ImageGrid({
attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
{detailPill}
{downloadPill}
</div>
);
}
@ -291,7 +349,9 @@ export function ImageGrid({
cropHeight={GAP}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -307,7 +367,9 @@ export function ImageGrid({
cropHeight={GAP}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
@ -326,7 +388,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -342,11 +406,15 @@ export function ImageGrid({
width={150}
attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
</div>
{detailPill}
{downloadPill}
</div>
);
}
@ -372,7 +440,9 @@ export function ImageGrid({
width={150}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -386,7 +456,9 @@ export function ImageGrid({
width={150}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
@ -405,7 +477,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -421,7 +495,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -439,11 +515,51 @@ export function ImageGrid({
overlayText={moreMessagesOverlayText}
attachment={attachments[4]}
url={getThumbnailUrl(attachments[4])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={undefined}
startDownload={undefined}
onError={onError}
/>
</div>
</div>
{detailPill}
{downloadPill}
</div>
);
}
function renderDownloadPill({
attachments,
i18n,
startDownloadClick,
startDownloadKeyDown,
}: {
attachments: ReadonlyArray<AttachmentForUIType>;
i18n: LocalizerType;
startDownloadClick: (event: React.MouseEvent) => void;
startDownloadKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
}): JSX.Element | null {
const downloadedOrPending = attachments.some(
attachment => attachment.path || attachment.pending
);
if (downloadedOrPending) {
return null;
}
return (
<button
type="button"
className="module-image-grid__download-pill"
aria-label={i18n('icu:startDownload')}
onClick={startDownloadClick}
onKeyDown={startDownloadKeyDown}
>
<div className="module-image-grid__download_pill__icon-wrapper">
<div className="module-image-grid__download_pill__download-icon" />
</div>
<div className="module-image-grid__download_pill__text-wrapper">
{i18n('icu:downloadNItems', { count: attachments.length })}
</div>
</button>
);
}

View file

@ -13,7 +13,7 @@ import React from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import getDirection from 'direction';
import { drop, groupBy, noop, orderBy, take, unescape } from 'lodash';
import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import type { ReadonlyDeep } from 'type-fest';
@ -52,7 +52,10 @@ import type { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment';
import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import {
canDisplayImage,
getExtensionForDisplay,
@ -101,6 +104,7 @@ import { UserText } from '../UserText';
import { getColorForCallLink } from '../../util/getColorForCallLink';
import { getKeyFromCallLink } from '../../util/callLinks';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
import { formatFileSize } from '../../util/formatFileSize';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -173,7 +177,7 @@ export type AudioAttachmentProps = {
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
theme: ThemeType | undefined;
attachment: AttachmentType;
attachment: AttachmentForUIType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
@ -226,7 +230,7 @@ export type PropsData = {
activeCallConversationId?: string;
text?: string;
textDirection: TextDirection;
textAttachment?: AttachmentType;
textAttachment?: AttachmentForUIType;
isEditedMessage?: boolean;
isSticker?: boolean;
isTargeted?: boolean;
@ -255,7 +259,7 @@ export type PropsData = {
| 'unblurredAvatarUrl'
>;
conversationType: ConversationTypeType;
attachments?: ReadonlyArray<AttachmentType>;
attachments?: ReadonlyArray<AttachmentForUIType>;
giftBadge?: GiftBadgeType;
payment?: AnyPaymentEvent;
quote?: {
@ -312,6 +316,8 @@ export type PropsData = {
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
item?: never;
// test-only, to force GIF's reduced motion experience
_forceTapToPlay?: boolean;
};
export type PropsHousekeeping = {
@ -344,10 +350,8 @@ export type PropsActions = {
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
markAttachmentAsCorrupted: (options: {
attachment: AttachmentType;
messageId: string;
@ -919,10 +923,12 @@ export class Message extends React.PureComponent<Props, State> {
const {
attachments,
attachmentDroppedDueToSize,
cancelAttachmentDownload,
conversationId,
direction,
expirationLength,
expirationTimestamp,
_forceTapToPlay,
i18n,
id,
isSticker,
@ -978,9 +984,10 @@ export class Message extends React.PureComponent<Props, State> {
<GIF
attachment={firstAttachment}
size={GIF_SIZE}
tabIndex={0}
_forceTapToPlay={_forceTapToPlay}
theme={theme}
i18n={i18n}
tabIndex={0}
onError={this.handleImageError}
showVisualAttachment={() => {
showLightbox({
@ -988,9 +995,13 @@ export class Message extends React.PureComponent<Props, State> {
messageId: id,
});
}}
kickOffAttachmentDownload={() => {
startDownload={() => {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
}}
cancelDownload={() => {
cancelAttachmentDownload({
messageId: id,
});
}}
@ -1026,12 +1037,14 @@ export class Message extends React.PureComponent<Props, State> {
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
tabIndex={tabIndex}
onClick={attachment => {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({ attachment, messageId: id });
} else {
showLightbox({ attachment, messageId: id });
}
showVisualAttachment={attachment => {
showLightbox({ attachment, messageId: id });
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
</div>
@ -1063,10 +1076,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp,
kickOffAttachmentDownload() {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
},
onCorrupted() {
markAttachmentAsCorrupted({
@ -1076,7 +1086,7 @@ export class Message extends React.PureComponent<Props, State> {
},
});
}
const { pending, fileName, fileSize, contentType } = firstAttachment;
const { pending, fileName, size, contentType } = firstAttachment;
const extension = getExtensionForDisplay({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || '');
@ -1100,7 +1110,6 @@ export class Message extends React.PureComponent<Props, State> {
if (!isDownloaded(firstAttachment)) {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
} else {
@ -1143,7 +1152,7 @@ export class Message extends React.PureComponent<Props, State> {
`module-message__generic-attachment__file-size--${direction}`
)}
>
{fileSize}
{formatFileSize(size)}
</div>
</div>
</button>
@ -1158,6 +1167,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
id,
kickOffAttachmentDownload,
cancelAttachmentDownload,
previews,
quote,
shouldCollapseAbove,
@ -1209,18 +1219,6 @@ export class Message extends React.PureComponent<Props, State> {
'module-message__link-preview--nonclickable': !isClickable,
}
);
const onPreviewImageClick = isClickable
? () => {
if (first.image && !isDownloaded(first.image)) {
kickOffAttachmentDownload({
attachment: first.image,
messageId: id,
});
return;
}
openLinkInWebBrowser(first.url);
}
: noop;
const contents = (
<>
{first.image && previewHasImage && isFullSizeImage ? (
@ -1233,7 +1231,15 @@ export class Message extends React.PureComponent<Props, State> {
onError={this.handleImageError}
i18n={i18n}
theme={theme}
onClick={onPreviewImageClick}
showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
) : null}
<div dir="auto" className="module-message__link-preview__content">
@ -1261,7 +1267,15 @@ export class Message extends React.PureComponent<Props, State> {
blurHash={first.image.blurHash}
onError={this.handleImageError}
i18n={i18n}
onClick={onPreviewImageClick}
showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
</div>
) : null}
@ -1970,7 +1984,6 @@ export class Message extends React.PureComponent<Props, State> {
return;
}
kickOffAttachmentDownload({
attachment: textAttachment,
messageId: id,
});
}}
@ -2574,10 +2587,7 @@ export class Message extends React.PureComponent<Props, State> {
}
if (attachments && !isDownloaded(attachments[0])) {
kickOffAttachmentDownload({
attachment: attachments[0],
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
return;
}
@ -2597,9 +2607,7 @@ export class Message extends React.PureComponent<Props, State> {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
kickOffAttachmentDownload({ attachment, messageId: id });
kickOffAttachmentDownload({ messageId: id });
return;
}
@ -2699,10 +2707,7 @@ export class Message extends React.PureComponent<Props, State> {
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
return;
}

View file

@ -85,6 +85,7 @@ export type PropsSmartActions = Pick<MessagePropsType, 'renderAudioAttachment'>;
export type PropsReduxActions = Pick<
MessagePropsType,
| 'cancelAttachmentDownload'
| 'checkForAccount'
| 'clearTargetedMessage'
| 'doubleCheckMissingQuoteReference'
@ -125,6 +126,7 @@ export function MessageDetail({
message,
receivedAt,
sentAt,
cancelAttachmentDownload,
checkForAccount,
clearTargetedMessage,
contactNameColor,
@ -330,6 +332,7 @@ export function MessageDetail({
<Message
{...message}
renderingContext="conversation/MessageDetail"
cancelAttachmentDownload={cancelAttachmentDownload}
checkForAccount={checkForAccount}
clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor}

View file

@ -108,6 +108,7 @@ const defaultMessageProps: TimelineMessagesProps = {
isSMS: false,
isSpoilerExpanded: {},
toggleSelectMessage: action('toggleSelectMessage'),
cancelAttachmentDownload: action('default--cancelAttachmentDownload'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
messageExpanded: action('default--message-expanded'),

View file

@ -296,6 +296,7 @@ const actions = () => ({
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showConversation: action('showConversation'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'),

View file

@ -76,6 +76,7 @@ const getDefaultProps = () => ({
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),
blockGroupLinkRequests: action('blockGroupLinkRequests'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'),

View file

@ -300,6 +300,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired,
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'),
@ -1400,6 +1401,22 @@ Gif.args = {
status: 'sent',
};
export const GifReducedMotion = Template.bind({});
GifReducedMotion.args = {
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
url: '/fixtures/cat-gif.mp4',
width: 400,
height: 332,
}),
],
status: 'sent',
_forceTapToPlay: true,
};
export const GifInAGroup = Template.bind({});
GifInAGroup.args = {
attachments: [
@ -1423,10 +1440,10 @@ NotDownloadedGif.args = {
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
fileSize: '188.61 KB',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
@ -1440,10 +1457,48 @@ PendingGif.args = {
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
fileSize: '188.61 KB',
size: 188610,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
};
export const DownloadingGif = Template.bind({});
DownloadingGif.args = {
attachments: [
fakeAttachment({
pending: true,
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
size: 188610,
totalDownloaded: 101010,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
};
export const PartialDownloadNotPendingGif = Template.bind({});
PartialDownloadNotPendingGif.args = {
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
size: 188610,
totalDownloaded: 101010,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
@ -1553,7 +1608,6 @@ OtherFileType.args = {
contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt',
url: 'my-resume.txt',
fileSize: '10MB',
}),
],
status: 'sent',
@ -1566,7 +1620,6 @@ OtherFileTypeWithCaption.args = {
contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt',
url: 'my-resume.txt',
fileSize: '10MB',
}),
],
status: 'sent',
@ -1581,7 +1634,6 @@ OtherFileTypeWithLongFilename.args = {
fileName:
'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip',
url: 'a2/a2334324darewer4234',
fileSize: '10MB',
}),
],
status: 'sent',

View file

@ -221,10 +221,7 @@ export function TimelineMessage(props: Props): JSX.Element {
// check if any attachment needs to be downloaded from servers
for (const attachment of attachments) {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
attachmentsInProgress += 1;
}