diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 8b203c733ef..b195aa1ac86 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -2127,6 +2127,33 @@ Signal Desktop makes use of the following open source projects. FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## mp4box + + Copyright (c) 2012. Telecom ParisTech/TSI/MM/GPAC Cyril Concolato + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ## mustache The MIT License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5f518f25d43..da486671880 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7527,6 +7527,18 @@ "message": "Can’t download story. You will need to share it again.", "description": "Description for image errors but when it is your own image" }, + "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" + }, + "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" + }, + "StoryCreator__error--video-error": { + "message": "Failed to load video", + "description": "Error string for when a video post to story fails" + }, "StoryCreator__text-bg--background": { "message": "Text has a white background color", "description": "Button label" diff --git a/package.json b/package.json index be7c4d45fd2..d9ccaafe9e2 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "memoizee": "0.4.14", "mkdirp": "0.5.2", "moment": "2.29.4", + "mp4box": "0.5.2", "mustache": "2.3.0", "node-fetch": "2.6.7", "node-forge": "1.3.0", diff --git a/ts/components/Stories.stories.tsx b/ts/components/Stories.stories.tsx index f7a7212c4a4..269193178e2 100644 --- a/ts/components/Stories.stories.tsx +++ b/ts/components/Stories.stories.tsx @@ -48,6 +48,7 @@ export default { renderStoryViewer: { action: true }, showConversation: { action: true }, showStoriesSettings: { action: true }, + showToast: { action: true }, stories: { defaultValue: [], }, diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index dcfa3cd40c3..fb87808e19a 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -15,6 +15,7 @@ import type { import type { LocalizerType } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator'; +import type { ShowToastActionCreatorType } from '../state/ducks/toast'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import { MyStories } from './MyStories'; import { StoriesPane } from './StoriesPane'; @@ -35,6 +36,7 @@ export type PropsType = { renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element; showConversation: ShowConversationType; showStoriesSettings: () => unknown; + showToast: ShowToastActionCreatorType; stories: Array; toggleHideStories: (conversationId: string) => unknown; toggleStoriesView: () => unknown; @@ -64,6 +66,7 @@ export const Stories = ({ renderStoryCreator, showConversation, showStoriesSettings, + showToast, stories, toggleHideStories, toggleStoriesView, @@ -118,6 +121,7 @@ export const Stories = ({ onStoriesSettings={showStoriesSettings} queueStoryDownload={queueStoryDownload} showConversation={showConversation} + showToast={showToast} stories={stories} toggleHideStories={toggleHideStories} toggleStoriesView={toggleStoriesView} diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index e9791397bfa..e183025c7e5 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -16,12 +16,18 @@ import type { } from '../types/Stories'; import type { LocalizerType } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; +import type { ShowToastActionCreatorType } from '../state/ducks/toast'; import { ContextMenu } from './ContextMenu'; import { MyStoriesButton } from './MyStoriesButton'; import { SearchInput } from './SearchInput'; import { StoryListItem } from './StoryListItem'; import { Theme } from '../util/theme'; +import { ToastType } from '../state/ducks/toast'; import { isNotNil } from '../util/isNotNil'; +import { + isVideoGoodForStories, + ReasonVideoNotGood, +} from '../util/isVideoGoodForStories'; const FUSE_OPTIONS: Fuse.IFuseOptions = { getFn: (story, path) => { @@ -70,6 +76,7 @@ export type PropsType = { onStoriesSettings: () => unknown; queueStoryDownload: (storyId: string) => unknown; showConversation: ShowConversationType; + showToast: ShowToastActionCreatorType; stories: Array; toggleHideStories: (conversationId: string) => unknown; toggleStoriesView: () => unknown; @@ -87,6 +94,7 @@ export const StoriesPane = ({ onStoriesSettings, queueStoryDownload, showConversation, + showToast, stories, toggleHideStories, toggleStoriesView, @@ -125,15 +133,35 @@ export const StoriesPane = ({ label: i18n('Stories__add-story--media'), onClick: () => { const input = document.createElement('input'); - input.accept = 'image/*,video/*'; + input.accept = 'image/*,video/mp4'; input.type = 'file'; - input.onchange = () => { + 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(); diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 06bd47674ea..1b1b415dba3 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -50,3 +50,18 @@ export const MessageBodyTooLong = Template.bind({}); MessageBodyTooLong.args = { toastType: ToastType.MessageBodyTooLong, }; + +export const StoryVideoTooLong = Template.bind({}); +StoryVideoTooLong.args = { + toastType: ToastType.StoryVideoTooLong, +}; + +export const StoryVideoUnsupported = Template.bind({}); +StoryVideoUnsupported.args = { + toastType: ToastType.StoryVideoUnsupported, +}; + +export const StoryVideoError = Template.bind({}); +StoryVideoError.args = { + toastType: ToastType.StoryVideoError, +}; diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 5666f24f4be..efffe1c5189 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -63,6 +63,30 @@ export const ToastManager = ({ ); } + if (toastType === ToastType.StoryVideoTooLong) { + return ( + + {i18n('StoryCreator__error--video-too-long')} + + ); + } + + if (toastType === ToastType.StoryVideoUnsupported) { + return ( + + {i18n('StoryCreator__error--video-unsupported')} + + ); + } + + if (toastType === ToastType.StoryVideoError) { + return ( + + {i18n('StoryCreator__error--video-error')} + + ); + } + strictAssert( toastType === undefined, `Unhandled toast of type: ${toastType}` diff --git a/ts/mp4box.d.ts b/ts/mp4box.d.ts new file mode 100644 index 00000000000..fa8ce3d046a --- /dev/null +++ b/ts/mp4box.d.ts @@ -0,0 +1,68 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +declare module 'mp4box' { + interface MP4MediaTrack { + alternate_group: number; + bitrate: number; + codec: string; + created: Date; + duration: number; + id: number; + language: string; + layer: number; + modified: Date; + movie_duration: number; + nb_samples: number; + timescale: number; + track_height: number; + track_width: number; + volume: number; + } + + interface MP4VideoData { + height: number; + width: number; + } + + interface MP4VideoTrack extends MP4MediaTrack { + video: MP4VideoData; + } + + interface MP4AudioData { + channel_count: number; + sample_rate: number; + sample_size: number; + } + + interface MP4AudioTrack extends MP4MediaTrack { + audio: MP4AudioData; + } + + type MP4Track = MP4VideoTrack | MP4AudioTrack; + + interface MP4Info { + brands: Array; + created: Date; + duration: number; + fragment_duration: number; + hasIOD: boolean; + isFragmented: boolean; + isProgressive: boolean; + mime: string; + modified: Date; + timescale: number; + tracks: Array; + } + + export type MP4ArrayBuffer = ArrayBuffer & { fileStart: number }; + + export interface MP4File { + appendBuffer(data: MP4ArrayBuffer): number; + flush(): void; + onError?: (e: string) => void; + onReady?: (info: MP4Info) => void; + } + + export function createFile(): MP4File; +} diff --git a/ts/state/ducks/toast.ts b/ts/state/ducks/toast.ts index 1deab8c0ee9..2837b42d88b 100644 --- a/ts/state/ducks/toast.ts +++ b/ts/state/ducks/toast.ts @@ -9,6 +9,9 @@ export enum ToastType { StoryMuted = 'StoryMuted', StoryReact = 'StoryReact', StoryReply = 'StoryReply', + StoryVideoError = 'StoryVideoError', + StoryVideoTooLong = 'StoryVideoTooLong', + StoryVideoUnsupported = 'StoryVideoUnsupported', } // State diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx index 8b311609e9b..9dc3183d523 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/Stories.tsx @@ -18,6 +18,7 @@ import { saveAttachment } from '../../util/saveAttachment'; import { useConversationsActions } from '../ducks/conversations'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoriesActions } from '../ducks/stories'; +import { useToastActions } from '../ducks/toast'; function renderStoryCreator({ file, @@ -31,6 +32,7 @@ export function SmartStories(): JSX.Element | null { const { showConversation, toggleHideStories } = useConversationsActions(); const { showStoriesSettings, toggleForwardMessageModal } = useGlobalModalActions(); + const { showToast } = useToastActions(); const i18n = useSelector(getIntl); @@ -70,6 +72,7 @@ export function SmartStories(): JSX.Element | null { renderStoryCreator={renderStoryCreator} showConversation={showConversation} showStoriesSettings={showStoriesSettings} + showToast={showToast} stories={stories} toggleHideStories={toggleHideStories} {...storiesActions} diff --git a/ts/util/isVideoGoodForStories.ts b/ts/util/isVideoGoodForStories.ts new file mode 100644 index 00000000000..cecae0f8ea0 --- /dev/null +++ b/ts/util/isVideoGoodForStories.ts @@ -0,0 +1,90 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import MP4Box from 'mp4box'; +import { VIDEO_MP4, isVideo } from '../types/MIME'; +import { SECOND } from './durations'; + +const MAX_VIDEO_DURATION = 30 * SECOND; + +type MP4ArrayBuffer = ArrayBuffer & { fileStart: number }; + +export enum ReasonVideoNotGood { + AllGoodNevermind = 'AllGoodNevermind', + CouldNotReadFile = 'CouldNotReadFile', + TooLong = 'TooLong', + UnsupportedCodec = 'UnsupportedCodec', + UnsupportedContainer = 'UnsupportedContainer', +} + +function createMp4ArrayBuffer(src: ArrayBuffer): MP4ArrayBuffer { + const arrayBuffer = new ArrayBuffer(src.byteLength); + new Uint8Array(arrayBuffer).set(new Uint8Array(src)); + + (arrayBuffer as MP4ArrayBuffer).fileStart = 0; + return arrayBuffer as MP4ArrayBuffer; +} + +export async function isVideoGoodForStories( + file: File +): Promise { + if (!isVideo(file.type)) { + return ReasonVideoNotGood.AllGoodNevermind; + } + + if (file.type !== VIDEO_MP4) { + return 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); + }); + + const arrayBuffer = createMp4ArrayBuffer(src); + + const mp4 = MP4Box.createFile(); + await new Promise((resolve, reject) => { + mp4.onReady = info => { + if (info.duration > 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; + } + return err; + } +} diff --git a/yarn.lock b/yarn.lock index af3839cbbca..d141f1701b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11838,6 +11838,11 @@ move-concurrently@^1.0.1: rimraf "^2.5.4" run-queue "^1.0.3" +mp4box@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/mp4box/-/mp4box-0.5.2.tgz#6a2d36fdd0e2d3f2f2bee446d2067edf0b3871bc" + integrity sha512-zRmGlvxy+YdW3Dmt+TR4xPHynbxwXtAQDTN/Fo9N3LMxaUlB2C5KmZpzYyGKy4c7k4Jf3RCR0A2pm9SZELOLXw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"