diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 27fe3726960..86e6f20a721 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/ts/components/MyStoryButton.tsx b/ts/components/MyStoryButton.tsx index 692e6d5595b..0c64c112f27 100644 --- a/ts/components/MyStoryButton.tsx +++ b/ts/components/MyStoryButton.tsx @@ -17,6 +17,7 @@ import { reduceStorySendStatus } from '../util/resolveStorySendStatus'; export type PropsType = { i18n: LocalizerType; + maxAttachmentSizeInKb: number; me: ConversationType; myStories: Array; 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 ( ; 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 => diff --git a/ts/components/StoriesAddStoryButton.tsx b/ts/components/StoriesAddStoryButton.tsx index c892526d6b2..23f31aa3dc5 100644 --- a/ts/components/StoriesAddStoryButton.tsx +++ b/ts/components/StoriesAddStoryButton.tsx @@ -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,68 +28,109 @@ export type PropsType = { export function StoriesAddStoryButton({ children, i18n, + maxAttachmentSizeInKb, moduleClassName, onAddStory, showToast, onContextMenuShowingChanged, }: PropsType): JSX.Element { + const [error, setError] = useState(); + + const onAddMedia = useCallback(() => { + const input = document.createElement('input'); + input.accept = 'image/*,video/mp4'; + input.type = 'file'; + input.onchange = async () => { + const file = input.files ? input.files[0] : undefined; + + if (!file) { + return; + } + + const result = await isVideoGoodForStories(file, { + maxAttachmentSizeInKb, + }); + + if ( + result.reason === ReasonVideoNotGood.UnsupportedCodec || + result.reason === ReasonVideoNotGood.UnsupportedContainer + ) { + showToast(ToastType.StoryVideoUnsupported); + return; + } + + if (result.reason === ReasonVideoNotGood.TooLong) { + setError( + i18n('icu:StoryCreator__error--video-too-long', { + maxDurationInSec: result.maxDurationInSec, + }) + ); + return; + } + + 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; + } + + onAddStory(file); + }; + input.click(); + }, [setError, showToast, i18n, maxAttachmentSizeInKb, onAddStory]); + return ( - { - const input = document.createElement('input'); - input.accept = 'image/*,video/mp4'; - input.type = 'file'; - input.onchange = async () => { - const file = input.files ? input.files[0] : undefined; - - if (!file) { - return; - } - - const result = await isVideoGoodForStories(file); - - if ( - result === ReasonVideoNotGood.UnsupportedCodec || - result === ReasonVideoNotGood.UnsupportedContainer - ) { - showToast(ToastType.StoryVideoUnsupported); - return; - } - - if (result === ReasonVideoNotGood.TooLong) { - showToast(ToastType.StoryVideoTooLong); - return; - } - - if (result !== ReasonVideoNotGood.AllGoodNevermind) { - showToast(ToastType.StoryVideoError); - return; - } - - onAddStory(file); - }; - input.click(); + <> + onAddStory(), - }, - ]} - moduleClassName={moduleClassName} - popperOptions={{ - placement: 'bottom', - strategy: 'absolute', - }} - theme={Theme.Dark} - > - {children} - + { + label: i18n('Stories__add-story--text'), + onClick: () => onAddStory(), + }, + ]} + moduleClassName={moduleClassName} + popperOptions={{ + placement: 'bottom', + strategy: 'absolute', + }} + theme={Theme.Dark} + > + {children} + + {error && ( + { + setError(undefined); + }, + style: 'affirmative', + text: i18n('Confirmation--confirm'), + }, + ]} + i18n={i18n} + onClose={() => { + setError(undefined); + }} + > + {error} + + )} + ); } diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 0ec9a25daf3..1f3f8a2ef07 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -59,6 +59,7 @@ export type PropsType = { getPreferredBadge: PreferredBadgeSelectorType; hiddenStories: Array; i18n: LocalizerType; + maxAttachmentSizeInKb: number; me: ConversationType; myStories: Array; 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({ - {i18n('StoryCreator__error--video-too-long')} - - ); - } - if (toastType === ToastType.StoryVideoUnsupported) { return ( diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index c97e44a3f33..67af6391813 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -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, diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 453b7095c3d..b8aaeca2ac8 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -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 || {} ); diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index 9cbf2257a53..30214e333a0 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -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} diff --git a/ts/types/AttachmentSize.ts b/ts/types/AttachmentSize.ts new file mode 100644 index 00000000000..45e0a9cb7b2 --- /dev/null +++ b/ts/types/AttachmentSize.ts @@ -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], + }; +} diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index c5b57c4425c..f419fb236ac 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -35,7 +35,6 @@ export enum ToastType { StoryReact = 'StoryReact', StoryReply = 'StoryReply', StoryVideoError = 'StoryVideoError', - StoryVideoTooLong = 'StoryVideoTooLong', StoryVideoUnsupported = 'StoryVideoUnsupported', TapToViewExpiredIncoming = 'TapToViewExpiredIncoming', TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing', diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts index 58c1e161a58..44b67aeb353 100644 --- a/ts/util/attachments.ts +++ b/ts/util/attachments.ts @@ -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: diff --git a/ts/util/isVideoGoodForStories.ts b/ts/util/isVideoGoodForStories.ts index 091abe00b61..1c671e5c632 100644 --- a/ts/util/isVideoGoodForStories.ts +++ b/ts/util/isVideoGoodForStories.ts @@ -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; + } +>; + +export type IsVideoGoodForStoriesOptionsType = Readonly<{ + maxAttachmentSizeInKb: number; +}>; + export async function isVideoGoodForStories( - file: File -): Promise { + file: File, + { maxAttachmentSizeInKb }: IsVideoGoodForStoriesOptionsType +): Promise { 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((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (reader.result) { - resolve(reader.result as ArrayBuffer); - } else { - reject(ReasonVideoNotGood.CouldNotReadFile); - } - }; - reader.readAsArrayBuffer(file); - }); + let src: ArrayBuffer; - const arrayBuffer = createMp4ArrayBuffer(src); + { + const { promise, resolve } = explodePromise(); - const mp4 = MP4Box.createFile(); - await new Promise((resolve, reject) => { - mp4.onReady = info => { - // mp4box returns a `duration` in `timescale` units - const seconds = info.duration / info.timescale; - const milliseconds = seconds * 1000; + const reader = new FileReader(); + reader.onload = () => { + if (reader.result) { + resolve(reader.result as ArrayBuffer); + } else { + resolve(undefined); + } + }; + reader.readAsArrayBuffer(file); - if (milliseconds > MAX_VIDEO_DURATION) { - reject(ReasonVideoNotGood.TooLong); - return; - } - - const codecs = /codecs="([\w,.]+)"/.exec(info.mime); - if (!codecs || !codecs[1]) { - reject(ReasonVideoNotGood.UnsupportedCodec); - return; - } - - const isH264 = codecs[1] - .split(',') - .some(codec => codec.startsWith('avc1')); - - if (!isH264) { - reject(ReasonVideoNotGood.UnsupportedCodec); - return; - } - - resolve(); - }; - mp4.appendBuffer(arrayBuffer); - }); - mp4.flush(); - - return ReasonVideoNotGood.AllGoodNevermind; - } catch (err) { - if (err instanceof Error) { - throw err; + const maybeSrc = await promise; + if (maybeSrc === undefined) { + return { reason: ReasonVideoNotGood.CouldNotReadFile }; } - return err; + + src = maybeSrc; + } + + if (src.byteLength / KIBIBYTE > maxAttachmentSizeInKb) { + return { + reason: ReasonVideoNotGood.TooBig, + renderDetails: getRenderDetailsForLimit(maxAttachmentSizeInKb), + }; + } + + const arrayBuffer = createMp4ArrayBuffer(src); + + const { promise, resolve } = + explodePromise(); + + const mp4 = MP4Box.createFile(); + mp4.onReady = info => { + // mp4box returns a `duration` in `timescale` units + const seconds = info.duration / info.timescale; + + 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]) { + resolve({ reason: ReasonVideoNotGood.UnsupportedCodec }); + return; + } + + const isH264 = codecs[1].split(',').some(codec => codec.startsWith('avc1')); + + if (!isH264) { + resolve({ reason: ReasonVideoNotGood.UnsupportedCodec }); + return; + } + + resolve({ reason: ReasonVideoNotGood.AllGoodNevermind }); + }; + mp4.appendBuffer(arrayBuffer); + try { + return await promise; + } finally { + mp4.flush(); } } diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts index c44930f110a..78b993184f8 100644 --- a/ts/util/processAttachment.ts +++ b/ts/util/processAttachment.ts @@ -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): 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