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

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