Descriptive error messages for video stories

This commit is contained in:
Fedor Indutny 2023-02-28 14:17:22 -08:00 committed by GitHub
parent c038c07b06
commit 4549291b7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 289 additions and 179 deletions

View file

@ -6313,12 +6313,20 @@
},
"StoryCreator__error--video-too-long": {
"message": "Cannot post video to story because it is too long",
"description": "Error string for when a video post to story fails"
"description": "(deleted 02/22/2023) Error string for when a video post to story fails"
},
"StoryCreator__error--video-unsupported": {
"message": "Cannot post video to story as it is an unsupported file format",
"description": "Error string for when a video post to story fails"
},
"icu:StoryCreator__error--video-too-long": {
"messageformat": "Cannot post video to story because it is longer than {maxDurationInSec, plural, one {1 second} other {# seconds}}.",
"description": "Error string for when a video post to story fails because video's duration is too long"
},
"icu:StoryCreator__error--video-too-big": {
"messageformat": "Cannot post video to story because it is larger than {limit}{units}.",
"description": "Error string for when a video post to story fails because video's file size is too big"
},
"StoryCreator__error--video-error": {
"message": "Failed to load video",
"description": "Error string for when a video post to story fails"

View file

@ -17,6 +17,7 @@ import { reduceStorySendStatus } from '../util/resolveStorySendStatus';
export type PropsType = {
i18n: LocalizerType;
maxAttachmentSizeInKb: number;
me: ConversationType;
myStories: Array<MyStoryType>;
onAddStory: () => unknown;
@ -32,6 +33,7 @@ function getNewestMyStory(story: MyStoryType): StoryViewType {
export function MyStoryButton({
i18n,
maxAttachmentSizeInKb,
me,
myStories,
onAddStory,
@ -60,6 +62,7 @@ export function MyStoryButton({
return (
<StoriesAddStoryButton
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
moduleClassName="StoryListItem StoryListItem--active-opactiy"
onAddStory={onAddStory}
showToast={showToast}
@ -112,6 +115,7 @@ export function MyStoryButton({
<div className="MyStories__avatar-container">
<StoriesAddStoryButton
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
moduleClassName="StoryListItem--active-opacity"
onAddStory={onAddStory}
showToast={showToast}

View file

@ -29,6 +29,9 @@ export default {
i18n: {
defaultValue: i18n,
},
maxAttachmentSizeInKb: {
defaultValue: 100 * 1024,
},
me: {
defaultValue: getDefaultConversation(),
},

View file

@ -35,6 +35,7 @@ export type PropsType = {
i18n: LocalizerType;
isStoriesSettingsVisible: boolean;
isViewingStory: boolean;
maxAttachmentSizeInKb: number;
me: ConversationType;
myStories: Array<MyStoryType>;
onForwardStory: (storyId: string) => unknown;
@ -64,6 +65,7 @@ export function Stories({
i18n,
isStoriesSettingsVisible,
isViewingStory,
maxAttachmentSizeInKb,
me,
myStories,
onForwardStory,
@ -122,6 +124,7 @@ export function Stories({
getPreferredBadge={getPreferredBadge}
hiddenStories={hiddenStories}
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me}
myStories={myStories}
onAddStory={file =>

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React from 'react';
import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../types/Util';
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
@ -13,10 +13,12 @@ import {
isVideoGoodForStories,
ReasonVideoNotGood,
} from '../util/isVideoGoodForStories';
import { ConfirmationDialog } from './ConfirmationDialog';
export type PropsType = {
children?: ReactNode;
i18n: LocalizerType;
maxAttachmentSizeInKb: number;
moduleClassName?: string;
onAddStory: (file?: File) => unknown;
onContextMenuShowingChanged?: (value: boolean) => void;
@ -26,20 +28,15 @@ export type PropsType = {
export function StoriesAddStoryButton({
children,
i18n,
maxAttachmentSizeInKb,
moduleClassName,
onAddStory,
showToast,
onContextMenuShowingChanged,
}: PropsType): JSX.Element {
return (
<ContextMenu
ariaLabel={i18n('Stories__add')}
i18n={i18n}
onMenuShowingChanged={onContextMenuShowingChanged}
menuOptions={[
{
label: i18n('Stories__add-story--media'),
onClick: () => {
const [error, setError] = useState<string | undefined>();
const onAddMedia = useCallback(() => {
const input = document.createElement('input');
input.accept = 'image/*,video/mp4';
input.type = 'file';
@ -50,22 +47,35 @@ export function StoriesAddStoryButton({
return;
}
const result = await isVideoGoodForStories(file);
const result = await isVideoGoodForStories(file, {
maxAttachmentSizeInKb,
});
if (
result === ReasonVideoNotGood.UnsupportedCodec ||
result === ReasonVideoNotGood.UnsupportedContainer
result.reason === ReasonVideoNotGood.UnsupportedCodec ||
result.reason === ReasonVideoNotGood.UnsupportedContainer
) {
showToast(ToastType.StoryVideoUnsupported);
return;
}
if (result === ReasonVideoNotGood.TooLong) {
showToast(ToastType.StoryVideoTooLong);
if (result.reason === ReasonVideoNotGood.TooLong) {
setError(
i18n('icu:StoryCreator__error--video-too-long', {
maxDurationInSec: result.maxDurationInSec,
})
);
return;
}
if (result !== ReasonVideoNotGood.AllGoodNevermind) {
if (result.reason === ReasonVideoNotGood.TooBig) {
setError(
i18n('icu:StoryCreator__error--video-too-big', result.renderDetails)
);
return;
}
if (result.reason !== ReasonVideoNotGood.AllGoodNevermind) {
showToast(ToastType.StoryVideoError);
return;
}
@ -73,7 +83,18 @@ export function StoriesAddStoryButton({
onAddStory(file);
};
input.click();
},
}, [setError, showToast, i18n, maxAttachmentSizeInKb, onAddStory]);
return (
<>
<ContextMenu
ariaLabel={i18n('Stories__add')}
i18n={i18n}
onMenuShowingChanged={onContextMenuShowingChanged}
menuOptions={[
{
label: i18n('Stories__add-story--media'),
onClick: onAddMedia,
},
{
label: i18n('Stories__add-story--text'),
@ -89,5 +110,27 @@ export function StoriesAddStoryButton({
>
{children}
</ContextMenu>
{error && (
<ConfirmationDialog
dialogName="StoriesAddStoryButton.error"
noDefaultCancelButton
actions={[
{
action: () => {
setError(undefined);
},
style: 'affirmative',
text: i18n('Confirmation--confirm'),
},
]}
i18n={i18n}
onClose={() => {
setError(undefined);
}}
>
{error}
</ConfirmationDialog>
)}
</>
);
}

View file

@ -59,6 +59,7 @@ export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType;
hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType;
maxAttachmentSizeInKb: number;
me: ConversationType;
myStories: Array<MyStoryType>;
onAddStory: (file?: File) => unknown;
@ -78,6 +79,7 @@ export function StoriesPane({
getPreferredBadge,
hiddenStories,
i18n,
maxAttachmentSizeInKb,
me,
myStories,
onAddStory,
@ -123,6 +125,7 @@ export function StoriesPane({
</div>
<StoriesAddStoryButton
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
moduleClassName="Stories__pane__add-story"
onAddStory={onAddStory}
showToast={showToast}
@ -155,6 +158,7 @@ export function StoriesPane({
<div className="Stories__pane__list">
<MyStoryButton
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me}
myStories={myStories}
onAddStory={onAddStory}

View file

@ -288,13 +288,6 @@ StoryVideoError.args = {
},
};
export const StoryVideoTooLong = Template.bind({});
StoryVideoTooLong.args = {
toast: {
toastType: ToastType.StoryVideoTooLong,
},
};
export const StoryVideoUnsupported = Template.bind({});
StoryVideoUnsupported.args = {
toast: {

View file

@ -278,14 +278,6 @@ export function ToastManager({
);
}
if (toastType === ToastType.StoryVideoTooLong) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-too-long')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoUnsupported) {
return (
<Toast onClose={hideToast}>

View file

@ -51,12 +51,14 @@ import {
resetLinkPreview,
suspendLinkPreviews,
} from '../../services/LinkPreview';
import { getMaximumAttachmentSizeInKb, KIBIBYTE } from '../../util/attachments';
import { getRecipientsByConversation } from '../../util/getRecipientsByConversation';
import {
getMaximumAttachmentSizeInKb,
getRenderDetailsForLimit,
processAttachment,
} from '../../util/processAttachment';
KIBIBYTE,
} from '../../types/AttachmentSize';
import { getValue as getRemoteConfigValue } from '../../RemoteConfig';
import { getRecipientsByConversation } from '../../util/getRecipientsByConversation';
import { processAttachment } from '../../util/processAttachment';
import { hasDraftAttachments } from '../../util/hasDraftAttachments';
import { isFileDangerous } from '../../util/isFileDangerous';
import { isImage, isVideo, stringToMIMEType } from '../../types/MIME';
@ -908,7 +910,7 @@ function preProcessAttachment(
return;
}
const limitKb = getMaximumAttachmentSizeInKb();
const limitKb = getMaximumAttachmentSizeInKb(getRemoteConfigValue);
if (file.size / KIBIBYTE > limitKb) {
return {
toastType: ToastType.FileSize,

View file

@ -63,7 +63,7 @@ const isRemoteConfigBucketEnabled = (
return innerIsBucketValueEnabled(name, flagValue, e164, uuid);
};
const getRemoteConfig = createSelector(
export const getRemoteConfig = createSelector(
getItems,
(state: ItemsStateType): ConfigMapType => state.remoteConfig || {}
);

View file

@ -8,12 +8,15 @@ import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import { SmartStoryCreator } from './StoryCreator';
import { Stories } from '../../components/Stories';
import { getMaximumAttachmentSizeInKb } from '../../types/AttachmentSize';
import type { ConfigKeyType } from '../../RemoteConfig';
import { getMe } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import {
getHasStoryViewReceiptSetting,
getPreferredLeftPaneWidth,
getRemoteConfig,
} from '../selectors/items';
import {
getAddStoryData,
@ -67,6 +70,13 @@ export function SmartStories(): JSX.Element | null {
const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting);
const remoteConfig = useSelector(getRemoteConfig);
const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb(
(name: ConfigKeyType) => {
const value = remoteConfig[name]?.value;
return value ? String(value) : undefined;
}
);
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
if (!isShowingStoriesView) {
@ -79,6 +89,7 @@ export function SmartStories(): JSX.Element | null {
getPreferredBadge={getPreferredBadge}
hiddenStories={hiddenStories}
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me}
myStories={myStories}
onForwardStory={toggleForwardMessageModal}

View file

@ -0,0 +1,46 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import type * as RemoteConfig from '../RemoteConfig';
export const KIBIBYTE = 1024;
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
export const getMaximumAttachmentSizeInKb = (
getValue: typeof RemoteConfig.getValue
): number => {
try {
return (
parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
) / KIBIBYTE
);
} catch (error) {
log.warn(
'Failed to parse integer out of global.attachments.maxBytes feature flag'
);
return DEFAULT_MAX / KIBIBYTE;
}
};
export function getRenderDetailsForLimit(limitKb: number): {
limit: string;
units: string;
} {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * KIBIBYTE;
do {
limit /= KIBIBYTE;
u += 1;
} while (limit >= KIBIBYTE && u < units.length - 1);
return {
limit: limit.toFixed(0),
units: units[u],
};
}

View file

@ -35,7 +35,6 @@ export enum ToastType {
StoryReact = 'StoryReact',
StoryReply = 'StoryReply',
StoryVideoError = 'StoryVideoError',
StoryVideoTooLong = 'StoryVideoTooLong',
StoryVideoUnsupported = 'StoryVideoUnsupported',
TapToViewExpiredIncoming = 'TapToViewExpiredIncoming',
TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing',

View file

@ -3,10 +3,7 @@
import { omit } from 'lodash';
import { blobToArrayBuffer } from 'blob-util';
import * as log from '../logging/log';
import { getValue } from '../RemoteConfig';
import { parseIntOrThrow } from './parseIntOrThrow';
import { scaleImageToLevel } from './scaleImageToLevel';
import type { AttachmentType } from '../types/Attachment';
import { canBeTranscoded } from '../types/Attachment';
@ -14,26 +11,6 @@ import type { LoggerType } from '../types/Logging';
import * as MIME from '../types/MIME';
import * as Errors from '../types/errors';
export const KIBIBYTE = 1024;
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
export const getMaximumAttachmentSizeInKb = (): number => {
try {
return (
parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
) / KIBIBYTE
);
} catch (error) {
log.warn(
'Failed to parse integer out of global.attachments.maxBytes feature flag'
);
return DEFAULT_MAX / KIBIBYTE;
}
};
// Upgrade steps
// NOTE: This step strips all EXIF metadata from JPEG images as
// part of re-encoding the image:

View file

@ -3,9 +3,10 @@
import MP4Box from 'mp4box';
import { VIDEO_MP4, isVideo } from '../types/MIME';
import { SECOND } from './durations';
import { KIBIBYTE, getRenderDetailsForLimit } from '../types/AttachmentSize';
import { explodePromise } from './explodePromise';
const MAX_VIDEO_DURATION = 30 * SECOND;
const MAX_VIDEO_DURATION_IN_SEC = 30;
type MP4ArrayBuffer = ArrayBuffer & { fileStart: number };
@ -13,6 +14,7 @@ export enum ReasonVideoNotGood {
AllGoodNevermind = 'AllGoodNevermind',
CouldNotReadFile = 'CouldNotReadFile',
TooLong = 'TooLong',
TooBig = 'TooBig',
UnsupportedCodec = 'UnsupportedCodec',
UnsupportedContainer = 'UnsupportedContainer',
}
@ -25,70 +27,106 @@ function createMp4ArrayBuffer(src: ArrayBuffer): MP4ArrayBuffer {
return arrayBuffer as MP4ArrayBuffer;
}
export type IsVideoGoodForStoriesResultType = Readonly<
| {
reason: Exclude<
ReasonVideoNotGood,
ReasonVideoNotGood.TooLong | ReasonVideoNotGood.TooBig
>;
}
| {
reason: ReasonVideoNotGood.TooLong;
maxDurationInSec: number;
}
| {
reason: ReasonVideoNotGood.TooBig;
renderDetails: ReturnType<typeof getRenderDetailsForLimit>;
}
>;
export type IsVideoGoodForStoriesOptionsType = Readonly<{
maxAttachmentSizeInKb: number;
}>;
export async function isVideoGoodForStories(
file: File
): Promise<ReasonVideoNotGood> {
file: File,
{ maxAttachmentSizeInKb }: IsVideoGoodForStoriesOptionsType
): Promise<IsVideoGoodForStoriesResultType> {
if (!isVideo(file.type)) {
return ReasonVideoNotGood.AllGoodNevermind;
return { reason: ReasonVideoNotGood.AllGoodNevermind };
}
if (file.type !== VIDEO_MP4) {
return ReasonVideoNotGood.UnsupportedContainer;
return { reason: ReasonVideoNotGood.UnsupportedContainer };
}
try {
const src = await new Promise<ArrayBuffer>((resolve, reject) => {
let src: ArrayBuffer;
{
const { promise, resolve } = explodePromise<ArrayBuffer | undefined>();
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
resolve(reader.result as ArrayBuffer);
} else {
reject(ReasonVideoNotGood.CouldNotReadFile);
resolve(undefined);
}
};
reader.readAsArrayBuffer(file);
});
const maybeSrc = await promise;
if (maybeSrc === undefined) {
return { reason: ReasonVideoNotGood.CouldNotReadFile };
}
src = maybeSrc;
}
if (src.byteLength / KIBIBYTE > maxAttachmentSizeInKb) {
return {
reason: ReasonVideoNotGood.TooBig,
renderDetails: getRenderDetailsForLimit(maxAttachmentSizeInKb),
};
}
const arrayBuffer = createMp4ArrayBuffer(src);
const { promise, resolve } =
explodePromise<IsVideoGoodForStoriesResultType>();
const mp4 = MP4Box.createFile();
await new Promise<void>((resolve, reject) => {
mp4.onReady = info => {
// mp4box returns a `duration` in `timescale` units
const seconds = info.duration / info.timescale;
const milliseconds = seconds * 1000;
if (milliseconds > MAX_VIDEO_DURATION) {
reject(ReasonVideoNotGood.TooLong);
if (seconds > MAX_VIDEO_DURATION_IN_SEC) {
resolve({
reason: ReasonVideoNotGood.TooLong,
maxDurationInSec: MAX_VIDEO_DURATION_IN_SEC,
});
return;
}
const codecs = /codecs="([\w,.]+)"/.exec(info.mime);
if (!codecs || !codecs[1]) {
reject(ReasonVideoNotGood.UnsupportedCodec);
resolve({ reason: ReasonVideoNotGood.UnsupportedCodec });
return;
}
const isH264 = codecs[1]
.split(',')
.some(codec => codec.startsWith('avc1'));
const isH264 = codecs[1].split(',').some(codec => codec.startsWith('avc1'));
if (!isH264) {
reject(ReasonVideoNotGood.UnsupportedCodec);
resolve({ reason: ReasonVideoNotGood.UnsupportedCodec });
return;
}
resolve();
resolve({ reason: ReasonVideoNotGood.AllGoodNevermind });
};
mp4.appendBuffer(arrayBuffer);
});
try {
return await promise;
} finally {
mp4.flush();
return ReasonVideoNotGood.AllGoodNevermind;
} catch (err) {
if (err instanceof Error) {
throw err;
}
return err;
}
}

View file

@ -6,8 +6,13 @@ import type {
AttachmentType,
InMemoryAttachmentDraftType,
} from '../types/Attachment';
import { getMaximumAttachmentSizeInKb, KIBIBYTE } from './attachments';
import {
getMaximumAttachmentSizeInKb,
getRenderDetailsForLimit,
KIBIBYTE,
} from '../types/AttachmentSize';
import * as Errors from '../types/errors';
import { getValue as getRemoteConfigValue } from '../RemoteConfig';
import { fileToBytes } from './fileToBytes';
import { handleImageAttachment } from './handleImageAttachment';
import { handleVideoAttachment } from './handleVideoAttachment';
@ -68,26 +73,8 @@ export async function processAttachment(
}
}
export function getRenderDetailsForLimit(limitKb: number): {
limit: string;
units: string;
} {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * KIBIBYTE;
do {
limit /= KIBIBYTE;
u += 1;
} while (limit >= KIBIBYTE && u < units.length - 1);
return {
limit: limit.toFixed(0),
units: units[u],
};
}
function isAttachmentSizeOkay(attachment: Readonly<AttachmentType>): boolean {
const limitKb = getMaximumAttachmentSizeInKb();
const limitKb = getMaximumAttachmentSizeInKb(getRemoteConfigValue);
// this needs to be cast properly
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore