Adds error states to story images

This commit is contained in:
Josh Perez 2022-08-03 20:38:41 -04:00 committed by GitHub
parent 782838c591
commit fcf7406dd4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 158 additions and 21 deletions

View file

@ -7481,9 +7481,19 @@
"message": "Hide", "message": "Hide",
"description": "Action button for the confirmation dialog to hide a story" "description": "Action button for the confirmation dialog to hide a story"
}, },
"StoryImage__error": { "StoryImage__error2": {
"message": "Error displaying image", "message": "Cant download story. $name$ will need to share it again.",
"description": "aria-label for image errors" "description": "Description for image errors",
"placeholders": {
"name": {
"content": "$1",
"example": "Clara"
}
}
},
"StoryImage__error--you": {
"message": "Cant download story. You will need to share it again.",
"description": "Description for image errors but when it is your own image"
}, },
"StoryCreator__text-bg": { "StoryCreator__text-bg": {
"message": "Toggle text background color", "message": "Toggle text background color",

View file

@ -33,6 +33,18 @@
width: 100%; width: 100%;
} }
&__error {
@include color-svg(
'../images/full-screen-flow/alert-outline.svg',
$color-white
);
align-items: center;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
}
&__spinner-bubble { &__spinner-bubble {
align-items: center; align-items: center;
background-color: $color-gray-75; background-color: $color-gray-75;

View file

@ -92,7 +92,9 @@ export const MyStories = ({
> >
<StoryImage <StoryImage
attachment={story.attachment} attachment={story.attachment}
firstName={i18n('you')}
i18n={i18n} i18n={i18n}
isMe
isThumbnail isThumbnail
label={i18n('MyStories__story')} label={i18n('MyStories__story')}
moduleClassName="MyStories__story__preview" moduleClassName="MyStories__story__preview"

View file

@ -83,7 +83,9 @@ export const MyStoriesButton = ({
{newestStory ? ( {newestStory ? (
<StoryImage <StoryImage
attachment={newestStory.attachment} attachment={newestStory.attachment}
firstName={i18n('you')}
i18n={i18n} i18n={i18n}
isMe
isThumbnail isThumbnail
label="" label=""
moduleClassName="StoryListItem__previews--image" moduleClassName="StoryListItem__previews--image"

View file

@ -27,6 +27,7 @@ function getDefaultProps(): PropsType {
url: '/fixtures/nathan-anderson-316188-unsplash.jpg', url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
thumbnail: fakeThumbnail('/fixtures/nathan-anderson-316188-unsplash.jpg'), thumbnail: fakeThumbnail('/fixtures/nathan-anderson-316188-unsplash.jpg'),
}), }),
firstName: 'Charlie',
i18n, i18n,
label: 'A story', label: 'A story',
queueStoryDownload: action('queueStoryDownload'), queueStoryDownload: action('queueStoryDownload'),
@ -129,3 +130,39 @@ export const Video = (): JSX.Element => (
})} })}
/> />
); );
export const ErrorImage = (): JSX.Element => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
error: true,
url: '/this/path/does/not/exist.jpg',
})}
/>
);
export const ErrorImageThumbnail = (): JSX.Element => (
<StoryImage
{...getDefaultProps()}
attachment={fakeAttachment({
error: true,
url: '/this/path/does/not/exist.jpg',
})}
isThumbnail
/>
);
ErrorImageThumbnail.story = {
name: 'Error Image (thumbnail)',
};
export const ErrorImageYou = (): JSX.Element => (
<StoryImage
{...getDefaultProps()}
isMe
attachment={fakeAttachment({
error: true,
url: '/this/path/does/not/exist.jpg',
})}
/>
);

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
@ -13,6 +13,7 @@ import { TextAttachment } from './TextAttachment';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import { import {
defaultBlurHash, defaultBlurHash,
hasFailed,
hasNotResolved, hasNotResolved,
isDownloaded, isDownloaded,
isDownloading, isDownloading,
@ -24,7 +25,9 @@ import { isVideoTypeSupported } from '../util/GoogleChrome';
export type PropsType = { export type PropsType = {
readonly attachment?: AttachmentType; readonly attachment?: AttachmentType;
readonly children?: ReactNode; readonly children?: ReactNode;
readonly firstName: string;
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly isMe?: boolean;
readonly isMuted?: boolean; readonly isMuted?: boolean;
readonly isPaused?: boolean; readonly isPaused?: boolean;
readonly isThumbnail?: boolean; readonly isThumbnail?: boolean;
@ -37,7 +40,9 @@ export type PropsType = {
export const StoryImage = ({ export const StoryImage = ({
attachment, attachment,
children, children,
firstName,
i18n, i18n,
isMe,
isMuted, isMuted,
isPaused, isPaused,
isThumbnail, isThumbnail,
@ -50,6 +55,7 @@ export const StoryImage = ({
(!isDownloaded(attachment) && !isDownloading(attachment)) || (!isDownloaded(attachment) && !isDownloading(attachment)) ||
hasNotResolved(attachment); hasNotResolved(attachment);
const [hasImgError, setHasImgError] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => { useEffect(() => {
@ -74,8 +80,10 @@ export const StoryImage = ({
return null; return null;
} }
const isPending = Boolean(attachment.pending) && !attachment.textAttachment; const hasError = hasImgError || hasFailed(attachment);
const isNotReadyToShow = hasNotResolved(attachment) || isPending; const isPending =
Boolean(attachment.pending) && !attachment.textAttachment && !hasError;
const isNotReadyToShow = hasNotResolved(attachment) || isPending || hasError;
const isSupportedVideo = isVideoTypeSupported(attachment.contentType); const isSupportedVideo = isVideoTypeSupported(attachment.contentType);
const getClassName = getClassNamesFor('StoryImage', moduleClassName); const getClassName = getClassNamesFor('StoryImage', moduleClassName);
@ -118,6 +126,7 @@ export const StoryImage = ({
<img <img
alt={label} alt={label}
className={getClassName('__image')} className={getClassName('__image')}
onError={() => setHasImgError(true)}
src={ src={
isThumbnail && attachment.thumbnail isThumbnail && attachment.thumbnail
? attachment.thumbnail.url ? attachment.thumbnail.url
@ -136,6 +145,17 @@ export const StoryImage = ({
</div> </div>
</div> </div>
); );
} else if (hasError) {
let content = <div className="StoryImage__error" />;
if (!isThumbnail) {
if (isMe) {
content = <>{i18n('StoryImage__error--you')}</>;
} else {
content = <>{i18n('StoryImage__error2', [firstName])}</>;
}
}
overlay = <div className="StoryImage__overlay-container">{content}</div>;
} }
return ( return (

View file

@ -179,6 +179,7 @@ export const StoryListItem = ({
<div className="StoryListItem__previews"> <div className="StoryListItem__previews">
<StoryImage <StoryImage
attachment={attachment} attachment={attachment}
firstName={firstName || title}
i18n={i18n} i18n={i18n}
isThumbnail isThumbnail
label="" label=""

View file

@ -471,6 +471,7 @@ export const StoryViewer = ({
<div className="StoryViewer__container"> <div className="StoryViewer__container">
<StoryImage <StoryImage
attachment={attachment} attachment={attachment}
firstName={firstName || title}
i18n={i18n} i18n={i18n}
isPaused={shouldPauseViewing} isPaused={shouldPauseViewing}
isMuted={isStoryMuted} isMuted={isStoryMuted}

View file

@ -222,7 +222,7 @@ async function _maybeStartJob(): Promise<void> {
delete _activeAttachmentDownloadJobs[job.id]; delete _activeAttachmentDownloadJobs[job.id];
try { try {
await removeAttachmentDownloadJob(job.id); await _markAttachmentAsFailed(job);
} catch (deleteError) { } catch (deleteError) {
log.error( log.error(
`${logId}: Failed to delete attachment job`, `${logId}: Failed to delete attachment job`,
@ -261,20 +261,10 @@ async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
const pending = true; const pending = true;
await setAttachmentDownloadJobPending(id, pending); await setAttachmentDownloadJobPending(id, pending);
message = window.MessageController.getById(messageId); message = await _getMessageById(id, messageId);
if (!message) {
const messageAttributes = await getMessageById(messageId);
if (!messageAttributes) {
logger.error(
`attachment_downloads/_runJob(${id}): ` +
'Source message not found, deleting job'
);
await _finishJob(null, id);
return;
}
strictAssert(messageId === messageAttributes.id, 'message id mismatch'); if (!message) {
message = window.MessageController.register(messageId, messageAttributes); return;
} }
await _addAttachmentToMessage( await _addAttachmentToMessage(
@ -370,6 +360,48 @@ async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
} }
} }
async function _markAttachmentAsFailed(
job: AttachmentDownloadJobType
): Promise<void> {
const { id, messageId, attachment, type, index } = job;
const message = await _getMessageById(id, messageId);
if (!message) {
return;
}
await _addAttachmentToMessage(
message,
_markAttachmentAsPermanentError(attachment),
{ type, index }
);
await _finishJob(message, id);
}
async function _getMessageById(
id: string,
messageId: string
): Promise<MessageModel | undefined> {
const message = window.MessageController.getById(messageId);
if (message) {
return message;
}
const messageAttributes = await getMessageById(messageId);
if (!messageAttributes) {
logger.error(
`attachment_downloads/_runJob(${id}): ` +
'Source message not found, deleting job'
);
await _finishJob(null, id);
return;
}
strictAssert(messageId === messageAttributes.id, 'message id mismatch');
return window.MessageController.register(messageId, messageAttributes);
}
async function _finishJob( async function _finishJob(
message: MessageModel | null | undefined, message: MessageModel | null | undefined,
id: string id: string

View file

@ -34,6 +34,7 @@ import { replaceIndex } from '../../util/replaceIndex';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage'; import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import { showToast } from '../../util/showToast'; import { showToast } from '../../util/showToast';
import { import {
hasFailed,
hasNotResolved, hasNotResolved,
isDownloaded, isDownloaded,
isDownloading, isDownloading,
@ -378,7 +379,10 @@ function markStoryRead(
return; return;
} }
if (!isDownloaded(matchingStory.attachment)) { if (
!isDownloaded(matchingStory.attachment) &&
!hasFailed(matchingStory.attachment)
) {
return; return;
} }
@ -449,6 +453,10 @@ function queueStoryDownload(
return; return;
} }
if (hasFailed(attachment)) {
return;
}
if (isDownloaded(attachment)) { if (isDownloaded(attachment)) {
if (!attachment.path) { if (!attachment.path) {
return; return;
@ -1001,6 +1009,8 @@ export function reducer(
const hasAttachmentDownloaded = const hasAttachmentDownloaded =
!isDownloaded(prevStory.attachment) && !isDownloaded(prevStory.attachment) &&
isDownloaded(newStory.attachment); isDownloaded(newStory.attachment);
const hasAttachmentFailed =
hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment);
const readStatusChanged = prevStory.readStatus !== newStory.readStatus; const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
const reactionsChanged = const reactionsChanged =
prevStory.reactions?.length !== newStory.reactions?.length; prevStory.reactions?.length !== newStory.reactions?.length;
@ -1014,6 +1024,7 @@ export function reducer(
const shouldReplace = const shouldReplace =
isDownloadingAttachment || isDownloadingAttachment ||
hasAttachmentDownloaded || hasAttachmentDownloaded ||
hasAttachmentFailed ||
hasBeenDeleted || hasBeenDeleted ||
hasSendStateChanged || hasSendStateChanged ||
readStatusChanged || readStatusChanged ||

View file

@ -768,6 +768,10 @@ export function isDownloading(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.downloadJobId && attachment.pending); return Boolean(attachment && attachment.downloadJobId && attachment.pending);
} }
export function hasFailed(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.error);
}
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean { export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {
const firstAttachment = attachments ? attachments[0] : null; const firstAttachment = attachments ? attachments[0] : null;

View file

@ -3,6 +3,7 @@
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { import {
hasFailed,
hasNotResolved, hasNotResolved,
isDownloaded, isDownloaded,
isGIF, isGIF,
@ -18,6 +19,10 @@ const MIN_TEXT_DURATION = 3 * SECOND;
export async function getStoryDuration( export async function getStoryDuration(
attachment: AttachmentType attachment: AttachmentType
): Promise<number | undefined> { ): Promise<number | undefined> {
if (hasFailed(attachment)) {
return DEFAULT_DURATION;
}
if (!isDownloaded(attachment) || hasNotResolved(attachment)) { if (!isDownloaded(attachment) || hasNotResolved(attachment)) {
return; return;
} }